20 Commits
Author SHA1 Message Date
AskaEth 0c2322bb92 docs(api): 契约 v0.93 — 审查预算的唯一真源(MEMORY_REVIEW_TIMEOUT_SEC 默认 20→90)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-13 23:28:39 +08:00
AskaEth 9f1f895059 Merge branch 'feat/memtimeout-single-source-of-truth-for-re' into dev 2026-10-13 23:28:34 +08:00
AskaEth 18ad2b89ab test(memory): 两条审查超时用例搬到正确位置(复审 p2-1,纯搬运)
复审指出 TestEmbeddingTimeoutFitsReviewBudget 的整段文档被夹在「它描述的
函数」与「两条新用例」之间成了孤儿文档,而该函数自己的文档只剩一句、且在
文件里出现两次(一次属于那段孤儿注释,一次紧贴真正的函数)。

只搬不改语义:把 TestReviewTimeoutExplicitEnvValueWins 与
TestReviewTimeoutEffectiveValueIsTheOnlyDefault 整体挪到
TestEmbeddingTimeoutFitsReviewBudget 之后,删掉重复的那句文案与多余的空行,
让那 21 行文档重新紧贴它描述的函数(与 dev 版的相对位置逐字相同)。

纯搬运的证据:拿「排序后的行集合」比对搬运前后(前者取自 HEAD)——
`diff <(git show HEAD:internal/runtime/memory/preinsert_recall_test.go | sort) <(sort internal/runtime/memory/preinsert_recall_test.go)`
只差两行:一句重复文案 + 一个空行,其余逐行一致。

顺带按「主张与落点」把本文件自查了一遍:每段注释都紧贴它描述的符号
(实测文档与函数之间没有空行:`awk '/^\/\// {doc=NR} /^func / {if (NR-doc!=1) print NR}'`),
且文中的位置指代(「那条防线在下面的 …」「下方 cfg.TimeoutSec != 0 那条」)
在搬运后依然成立。
2026-10-13 23:21:25 +08:00
AskaEth 42a07cb454 fix(config): 单条入库审查的墙钟上限收敛到唯一真源(M16)
生产启动行仍是 timeout=20s —— M15 把 memory 包的 defaultReviewTimeout
抬到 90s 的修复**从未生效**:config 层的 env tag 自己带着 default:"20",
env 层永远产出非零值,于是 memory 侧「≤0 才回落默认」的归一化成了死代码。

修法(二选一中的「① 让 memory 侧成为唯一真源」):去掉 config 侧
MEMORY_REVIEW_TIMEOUT_SEC 的字面默认值,未配置时读出 0 = 「没配」,
由 normalizeReviewConfig 单点落地 90s。

生效值:
  .env / env 没写这个键 ⇒ 90s(唯一真源)
  写了正数            ⇒ 原样生效(不做任何「保底抬到 90」的加码)
  写了 0 / 负数       ⇒ 90s(与项目其它旋钮同一口径:≤0 = 用默认)

新增测试(走真实组装路径,不绕过任何一层):
  config.LoadMemoryReviewConfig → ReviewConfig.Timeout →
  NewPreInsertReviewer → normalizeReviewConfig → 生效值(= 启动行打印的 timeout=)
断言生效值严格大于「实测最坏召回链 57s + T1 子超时 8s」,并断言启动行打印它。
变异自证(修前红):把 tag 的 default:"20" 加回去 ⇒ 报出
  「组装后 timeout = 20s」+「生效值 20s != 唯一真源 1m30s」

原有 TestEmbeddingTimeoutFitsReviewBudget 拦不住这条,因为它比的是**本包常量**,
看不见 env 层;注释里已写明「比常量」与「比组装后生效值」的区别(防后人重蹈)。
2026-10-13 23:09:19 +08:00
AskaEth 202cd8535e Merge branch 'feat/embudget-fix-embedding-request-amplifica' into dev 2026-10-13 22:59:42 +08:00
AskaEth 9ff0421888 Merge branch 'feat/mempanel-memory-tidy-tuning-panel-write' into dev 2026-10-13 22:59:38 +08:00
AskaEth f6c47b0ae1 fix(memory): 清掉两处与实现相反的旧说法 + 并发哨兵改成窗口重叠(变异 8/8 红)
review P2 两条。

① 旧说法没删净(同类第 N 次:改了 A,同一主张还留在 B / C)。这次按「**这句主张的所有
落点**」重扫了一遍我这一片里含 Stop / Start / 重启 / 心跳已停 的注释,改掉两处与实现
正面打架的:
- applyTidyConfig 的注释还写着「调用方(ApplyPanelTuning)在 Stop 期间不能持 runMu」
  「前提仍是心跳已停」—— 而同文件的文件头刚写过「调优路径上一个 Start/Stop 都没有」,
  同一文件里两段话自相矛盾;
- 整理档那条并发哨兵的循环注释还写着「Stop 把 running 置为 false,忘了重启就会静默
  停摆」,而该循环里根本没有 Stop/Start(改成「唤醒不是停表」,并把 Stop/Start 那版
  明确标成旧版)。
顺带把文件头「所有读点都收进访问器」的说法改准:此外还有一处**同一把锁下**的相等比较
(ApplyPanelTuning 里的 s.cfg == next),它本身不需要访问器。

② 新哨兵是概率性守卫:固定 40 轮时撤回 DryRun 同步只红 4/8(写方那几十次覆写在微秒级
就跑完,读方还在慢端点里打转)⇒ 文档规定的 -count=1 会漏掉约一半回归。改成**两侧各自
跑满同一个 300ms 墙钟窗口**(写窗口与读窗口必然重叠,不靠快慢概率),读方两个活端点各
起两路;写方每轮换一个值(否则 ApplyPanelTuning 会被「配置没变」的早退挡掉,根本不写)。
实测:撤回 DryRun 同步红 **8/8**、撤回 ManualRun 同步红 **8/8**(各 8 次 -race -count=1);
干净 tip `-race -count=5` 全绿、三条哨兵一起 `-count=3` 全绿;该哨兵耗时 0.35s(原 0.18s)。

顺带把 clipJSON 用例注释里那句改准:40 字节处被切掉的是第 13 个汉字的**第三个字节**
(前两个字节被留下),而不是先前写的「第三个字节上」。
2026-10-13 22:50:38 +08:00
AskaEth 4df8d6f53a fix(memory): 按字段扫清 s.cfg 的其余无锁读(P1)+ 回显文案按 rune 截断
review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁
访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**:

- TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与
  ?async=1 那条会起后台 goroutine 的版本(真机 90s 级);
- TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run;
- (原本也漏的)Enabled() 与 Start 的启动日志块。

按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器
内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处;
s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。

新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。
修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回
ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原
(diff 0 差异)。

顺手两条(塔指定):
- 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成
  唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。
- 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经
  JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的
  clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀
  错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
2026-10-13 22:33:07 +08:00
AskaEth 97c0c1b94a feat(memory): 调优重排改为唤醒通道,不再 Stop/Start(消灭与到期清扫的耦合)
按塔的裁定把「重排心跳」从生命周期操作改成唤醒通道,调优路径上一个 Start/Stop
都没有了。

- tidy_service.go:TidyService 新增 wake(cap 1 + 非阻塞发)与 wakeMu/wakeCancel;
  loop 的等待段抽成 waitForTick,三态判定(睡满 / 被调优唤醒 / 停);被唤醒只
  重算下一拍、**绝不**跑轮。调度读(nextDelay / dueNow)改走 scheduleSnapshot,
  在同一把锁下同取「cfg + lastRun」,于是运行期写 cfg 不必再把后台件停下来。
  Start / Stop / wg / running 与 Enabled 早退语义**一个字节没动**。
- tidy_service_tuning.go:ApplyPanelTuning 变成「持 runMu 写 cfg → 发一次唤醒」。
  唤醒 = 一次性标记(保证不丢:心跳不在等时标记留在缓冲里)**加上**取消当前那次
  等待(保证立刻生效,否则 24h→3h 要等原来那一觉睡完)。

两条冲突就此消失,各配一条可复现断言:
- **面板保存不再触发到期清扫、也不重置清扫 ticker**:新用例在保存前后各造一行已
  到期的记忆,保存之后那行必须**还在**、清扫审计计数必须不变;该用例自带活对照
  (启动清扫会删掉第一行),因此「行还在」不是量具失灵。若保存走 Stop/Start,清扫
  的「启动即扫一轮」会立刻把它删掉。
- **调优路径上不再存在任何 Start 调用**(结构上消灭交叉 Start/Stop):包内生产代码
  已 grep 不到任何 `.Start(` / `.Stop(` 调用,Start 与 Stop 各只剩组合根那一处调用点
  (`cmd/cyrene/main.go` / `cmd/cyrene/wire_services.go`),M3 注释里那条「Start/Stop
  各只调用一次」的不变式因此重新成立;交叉场景由带真心跳的调优用例在 `-race` 下跑过
  (不再 panic)。

热生效没退化:原来那条「23h30m → 30m」的断言按新机制重写 —— 用一个「听得懂 ctx
取消」的假等待看两觉的时长(保存前 23h30m、保存当场的第二觉 30m),并补上「唤醒不
跑轮(lastRun 不前移)」「唤醒后 running 仍为 true」。

变异自证(都跑过再还原,`diff` 逐字一致):
- 去掉 `notifyReschedule` 调用 ⇒「被唤醒后的第二觉」等不到 ⇒ 用例红;
- 只留标记、去掉「取消当前等待」那一半 ⇒ 同一条红(两半都必需)。

M3 的守卫 `expire_loop_test.go` **一行未动**:它的假 wait 只认 ctx,父 ctx 取消时
返回 false,在新机制下语义不变(无人唤醒时照样把心跳停住)。
2026-10-13 22:17:22 +08:00
AskaEth bdf3f3edaf docs(embedding,memory): 复核 p2 的三处措辞改正(纯注释,零行为改动)
review-m15 round1 判 p2-1items(fix-then-merge),三条都属「把证据能支撑什么写准」:

1. api.go 原写「最坏也只是这一片失败」与实现语义**相反** —— Embed 的实际语义是
   「任一片失败 ⇒ 整条调用失败、其余分片已算完的工作量一并丢弃」。改为与该包
   budget.go 里已经写对的那一句对齐,并点明调用方据此降级(记忆一条不丢、判定规则
   一字不改,变的是这一条审查的召回质量)。
2. 「尖峰与批次无关」是**过度断言**:原证据只支持「至少存在一个与批次无关的外部/
   争用分量」(4 条/片也实测到 7.139s);越界的**频率**是否随批次上升,1/40 vs 2/40
   的样本不足以判定 ⇒ 改标为「归因未定」,并明说批上限取 8 的依据始终是硬证据
   (16 条/片实测越界 + 真实代码路径 3 次里 2 次越界),**不依赖**任何成因推断
   (原文与同文件「16 条越界故不取更大」的理由自相矛盾)。同时补上独立复核窗口的
   对照(review-m15:24 次单请求 0 尖峰)说明尖峰强依赖窗口负载。
3. budget.go 的不变式不再被读成延迟保证:明确定性为「**稳态、空闲机器的建模拟合**,
   用途是构造批形状」,并点名真正的兜底(90s 审查预算按整条链算 + 任一片失败即降级
   的路径 + 已登记的链路韧性遗留);结论句由「8 条/片不越界」改为「**空闲实测下**不
   越界、负载下仍偶发越界」;perItemBudget 与 callBudgetShare 注释里「另一半/余量」
   的同口径措辞一并改正(加一句:这是建模余量,不是单请求一定不越界的保证)。

另:preinsert_config.go 里「设计稿那一行待同步」已过期 —— 塔同日已把 design/23 §15
同步为 Timeout=90s,改为「已按实测同步」。

验证:go build ./... / go vet ./... 干净;go test -count=1 ./... 全绿;
go test -race -count=1 ./internal/runtime/embedding/... ./internal/runtime/memory/...
全绿;diff 中无非注释行(纯文档改动)。
2026-10-13 22:12:11 +08:00
AskaEth a87fbeb494 fix(memory): 面板调优引入的两处运行期写入面收口(-race 修前红 → 修后绿)
一、审查档(preinsert.go / preinsert_gate.go):面板调优会在**运行期**改 cfg 的
两个字段(DisableIngestGate / TempTTL),而读这两个字段的两处不持锁
(gateEnabled 在同步入库闸上、deferDeadline 在第五态 defer 上)。
- 读侧改走新的 tuningSnapshot()(r.mu 由 Mutex 换成 RWMutex,读锁下取整份快照);
- **只**收这两个字段的读点:其余读点读的是构造期冻结的字段,不与写方交手,
  故刻意不给入库热路径加无谓的锁;注释写明「将来面板再接管别的字段,那些读点
  必须与它一起收」;
- 锁序:写方持锁期间只做字段赋值、不取任何别的锁(normalize/回落都在取锁之前
  跑完)⇒ 不引入新锁序。

二、整理档(tidy_service_tuning.go):修掉我自己的那条真竞争 —— 原顺序是
「Stop → Start → 写 cfg」,而新起的循环**立刻**读 s.cfg 重算下一拍,与写入并排
(那一拍还可能按旧配置算出来)。改成「Stop → 写 → Start」。
顺带去掉 Stop 的超时分支:超时意味着 wg 的 Wait 仍在飞,紧接着 Start 的 Add 会
踩上 M3 在 tidy_service.go 明文写下的 WaitGroup 复用纪律(-race 下 panic);`Stop`
期间也不持 runMu,避免与「心跳正要开跑一轮」互相等死。

证据(全部逐条跑过再还原,还原用 diff 逐字校验为 0 差异):
- 审查档:撤回读侧修复 ⇒ 2 处 DATA RACE(写 preinsert_tuning.go 的两个字段 × 读
  gateEnabled / deferDeadline);恢复 ⇒ 绿。
- 整理档:把顺序改回「先起后写」⇒ DATA RACE(写 applyTidyConfig × 读 nextDelay);
  恢复 ⇒ 绿。
- 新增两条只在 -race 下有意义的回归哨兵(tidy_service_tuning_test.go):审查档真
  并发读写;整理档用**真心跳**当读者(不伪造生产不存在的调用方,那个入口在包里
  只有心跳 goroutine 会调)。

热路径成本:新增的同步是「一次无竞争 RLock + 整份小结构体快照」,隔离实测约
4ns/次(5e6 次共 22.9ms);入库闸那条测量用例的批均值在 2.62ms → 2.71~2.81ms
之间波动,落在那条用例自身的运行间噪声里(同一份代码连跑两次,纯写入基线就从
39µs 飘到 60µs)。
2026-10-13 22:11:41 +08:00
AskaEth f3fb097630 perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字):
- 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s
  比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写**
  (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因)
- 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变
  —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数
- 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 /
  5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内

改动:
- embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍)
  ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立
  (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内)
- embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、
  编译失败),与 tidy_config.go 那条同手法
- memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、
  与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的
  最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算)

测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 +
16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s;
preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。
变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红;
perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。

遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求),
解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点
慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测
任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是
重试/分片级容错,调常量解决不了。
2026-10-13 21:59:31 +08:00
AskaEth e9e7b7dcb2 test(memory): memory-tidy.json 写路径(补丁保留其它键 / 值没变不落盘 / 坏文件拒写 / 原子写)
塔批准的 scope 里点名了 internal/config/runtimeconfig/memorytidy_save_test.go,
补上这四个口径的包内单测(网关侧另有端到端用例覆盖三态与审计)。
2026-10-13 21:59:18 +08:00
AskaEth 27ee0a8149 test(memory): 拆分调优写端点测试(原文件 524 行 > AGENTS §10 的 500 行上限)
守卫用例(非法值不落盘 / 坏文件不覆盖 / 同值不重写 / 热生效两种结果)移到
admin_memory_tuning_guard_test.go,helper 与读侧用例留在 admin_memory_tuning_test.go;
口径不变,两个文件都 ≤300 行。
2026-10-13 21:59:18 +08:00
AskaEth eddf921229 feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)
主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。

此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。

后端(读写端点,契约待塔登记 v0.92):
- GET  /api/v1/admin/memories/tidy/tuning  读盘上 7 键值 + 取值规则 specs +
  缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT  同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
  个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
  memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
  SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
  (t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
  重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
  未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」

前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
  env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
  mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
  error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
  「没有候选可比」显式区分)

验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
2026-10-13 21:59:18 +08:00
AskaEth 17c41265e8 Merge branch 'feat/memexpire-bounded-expiry-sweep-with-audi' into dev 2026-10-13 21:59:01 +08:00
AskaEth 8a274e120d docs(memory): 到期清扫的注释改用符号引用,不再写会漂的行号
review r2(p2-1items)指出:r1 那笔注释里的行号是旧值 —— 我在文件更前面插了
12 行之后 `Stop` 整体下移,而注释里仍写着「`Stop` 在 `tidy_service.go:214` 就放锁,
到 `:219` 才 Wait」(214 是 `Stop` 的文档注释行、219 是 `s.mu.Lock()`)。

行号会漂、符号不会,因此改成符号 + 相对位置:
- 「在同一个 `func (s *TidyService) Stop` 里,`s.mu.Unlock()` 在前、`s.wg.Wait()`
  (另一个 goroutine 里)在后,两者之间没有锁」;
- 「`Start` 只在组合根启动期调一次(`cmd/cyrene/main.go` 的
  `a.memoryTidy.Start(ctx)`)、`Stop` 只在关停链里调一次(`cmd/cyrene/wire_services.go`
  的 `_ = a.memoryTidy.Stop(ctx)`)」。

同一类问题只此一处遗留(`expire.go` 里「照 `preinsert_gc.go:65-69` 的批删范式」
同样改成符号:`ingestReviewSweepBatchSize` / `maxIngestReviewSweepBatches` 那一对
常量)。已 grep 全部改动文件确认再无 `*.go:<行号>` 形式的引用。

(本提交信息刻意不复述任何行号。)
2026-10-13 21:54:38 +08:00
AskaEth 69210a8586 docs(memory): 改正 Start 里那笔 Add 的并发注释,并补 Enabled=false 回归测试
review r1(p2-1items)指出:`tidy_service.go` 新加的那段注释(以及 2ba0a40 的提交
信息)写着「计数在这把锁里加 …… `Stop` 也持这把锁,这样 Add 与 Stop 的 `wg.Wait`
不可能撞在一起」。**这句不成立**:`Stop` 在 `tidy_service.go:214` 就放锁,到 `:219`
(另一个 goroutine 里)才 `wg.Wait`。锁只串行化 `running` 翻转与两笔 Add,
**不覆盖 Wait**。reviewer 用交叉 Start/Stop 探针在 -race 下复现了
`panic: sync: WaitGroup is reused before previous Wait has returned`,并实测该 panic
在 M3 之前的基线(20051f0)上同样可复现 ⇒ 既有性质,本切片不改行为,只改说法:

- 照真实理由改写注释:锁内加是为了让 `Stop` 的 `running` 判定与这两笔 Add
  **原子化**(杜绝「Stop 已看到 running=false 早退」与「Wait 漏掉一笔尚未 Add 的
  goroutine」两个窗口);
- 明确写出这把锁**不**覆盖 `Stop` 的 `wg.Wait`,WaitGroup 的复用纪律靠
  「`Start`/`Stop` 在进程生命周期各只调一次」维持;
- 并写明那个交叉探针的**当前调用点不可达**(`Start` 只在 `cmd/cyrene/main.go:97`
  调、`Stop` 只在 `cmd/cyrene/wire_services.go:212` 调),免得后人看到「有窗口但
  不可达」以为是漏改。

同时补上 reviewer 点出的真实缺口:`Enabled=false ⇒ 清扫不跑` 此前只有注释、没有
已提交的回归测试。新增 TestTidyServiceStart_整理关掉时清扫不跑,真跑 Start 后断言
「没有删除发生 + 没有审计 + running 保持 false」三件一起(不是只断言 cfg.Enabled
字段那种假牙)。两条断言各有牙:把 Enabled 闸改成恒 false ⇒ 红在 running 那一条;
在 Enabled 早退分支里偷偷起一个清扫(running 仍为 false)⇒ 红在「到期行却被删了」。
2026-10-13 21:46:24 +08:00
AskaEth 44c25eb03b refactor(memory): 到期清扫挂点从 wait 注入缝改为 TidyService.Start
塔裁定:清扫不许挂在注入缝上 —— 包一层 s.wait 会让「清扫是否存活」依赖于
「loop 每一轮都调 s.wait」这个**没有任何契约固定**的假设;将来谁换了等待原语、
或把循环改成 select + 自己的 timer,清扫会**静默消失**(不报错、不红测试,
只是临时期限再也删不掉)。后台件起在 Start 里,读者一眼看到它们同生共死。

- tidy_service.go:Start 里紧跟 go s.loop(cctx) 起 go s.expirySweepLoop(cctx),
  wg 计数与既有那一笔同处(同一把锁内加,避免 Add 与 Stop 的 wg.Wait 相撞)。
  `if !s.cfg.Enabled { … return }` 仍在其前 ⇒ 语义与改动前逐字相同:整理被关掉
  ⇒ 清扫不跑(与 preinsert_gc 随审查服务启停同口径)。
- expire_loop.go:删掉 AttachExpirySweep 与对 s.wait 的包裹;文件头写明
  「挂点必须是 Start 里那处显式的 go」,防止后人改回注入缝。
- expire_loop_test.go:那条运行时对照改写成 TestTidyServiceStart_清扫随服务起停
  —— 真 Start、真 Stop(Stop 不超时即证明清扫随 ctx 退出且登记在同一个 wg)。
- wire_memory_tidy.go:去掉组合根的挂载调用,注释改成「起在 Start 里,组合根零接线」。
- wire_memory_tidy_test.go:把 go s.expirySweepLoop(cctx) 钉进 TestMemoryTidyIsWired
  的 seams(摘掉那一行 ⇒ 用例红,已实测)。
2026-10-13 21:43:27 +08:00
AskaEth a017ab6cf8 feat(memory): 临时记忆到期物理删除(有界清扫 + 同步清向量 + 删前审计)
design/23 §19 的第 ② / ③ 步:读侧时效闸(M1)只负责「看不见」,本切片负责
「真删掉」。三条护栏照 §19.3:删除是本包内唯一 DELETE 出口、有界(500 行/批 ×
20 批)、删前留审计。

- internal/runtime/memory/expire.go:扫描集合写死为「expires_at ≠ '' ∧
  expires_at ≤ now ∧ status='active' ∧ 来源非受限」。空 expires_at(长期/日常
  记忆,含 M2 登记的「INSERT 与补写之间中断」窗口)结构性进不了集合;受限来源
  复用 nonOwnerSource(与整理/审查同一处判据)。键集游标 (expires_at, rowid)
  保证被跳过的行只读一次、扫描位置一定前移。审计串 = id + persona_id + scope +
  expires_at + 按 rune 截断(48)的摘要 —— 本仓唯一一处允许携带内容片段的审计。
- internal/runtime/memory/expire_loop.go:清扫循环挂在 TidyService 的生命周期上
  (AttachExpirySweep 由组合根调用),随心跳 ctx 跑、随 Stop 停;一轮只写一行
  汇总日志,逐条的那一行进审计。
- internal/runtime/memory/vector/delete.go:新增可选删除能力 vector.Deleter +
  两个后端实现,删行时同步清向量(M1 reviewer 的 finding:过期向量占 top-K 预算)。
- cmd/cyrene/wire_memory_tidy.go:挂上清扫;顺带把已过时的「这十键」改成「这十一
  键」(M2 加了第十一键 tempTtlSec)。
2026-10-13 21:43:27 +08:00
43 changed files with 5254 additions and 110 deletions

No files matched your search

+16 -4
View File
@@ -20,7 +20,7 @@
> **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **该默认值已由 v0.93 改为 `90`** —— 当时登记的值是准确的,见 §6 v0.93);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
> **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。
> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。
@@ -1543,7 +1543,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) |
| `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
| `MEMORY_REVIEW_TIMEOUT_SEC` | `20` | **v0.77 登记**:单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN |
| `MEMORY_REVIEW_TIMEOUT_SEC` | **`90`**(v0.93 起;v0.77–v0.92 为 `20`) | 单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN。🔴 **默认值的唯一真源在 memory 包**(`internal/runtime/memory` 的 `defaultReviewTimeout`)—— 该 env 键**不设自带默认值**,未配置读出 `0` = 「没配」,由 `normalizeReviewConfig` 单点落地;**显式写正数才压过它**(不做「保底抬到 90」的加码)。⚠️ `20s` 已被实测证伪:批量 8 时一条完整召回链墙钟 **21.5–28.2s** ⇒ 20s 预算会让审查**整条失败且不写留痕**(见 §6 v0.93) |
| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) |
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
@@ -1573,7 +1573,19 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.92(当前契约版本)
### v0.93(当前契约版本)
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
- 🔴 **`MEMORY_REVIEW_TIMEOUT_SEC` 的默认值 `20` → `90`**,并把默认值**收到唯一真源**:它**不再自带 env tag 默认值**,未配置读出 `0` = 「没配」,由 memory 包 `normalizeReviewConfig` 单点落地 `defaultReviewTimeout`。
- **未配置 ⇒ `90s`**;**显式写正数 ⇒ 原样生效**(**不做**「保底抬到 90」的加码);**写 `0` / 负数 ⇒ `90s`**。
- 🔴 **它修的是一个「看起来改了、其实没改」的事故**:v0.92 那批切片把 memory 包的 `defaultReviewTimeout` 从 `20s` 改成 `90s`,但 config 包的 env tag `default:"20"` **永远产出非零的 20** ⇒ `normalizeReviewConfig` 的「`≤ 0` 才填默认」分支**永远不走** ⇒ **90s 是死代码**,线上启动行仍打印 `timeout=20s`。
- **为什么必须修**:批量上限抬到 8 之后,一条完整召回链实测 **21.5–28.2s** ⇒ 20s 预算下审查**整条失败、且不写留痕** ⇒ 召回降级、灰区恒空、T1 发不出调用 ⇒ 「重复记忆收不动」这条链**只修了一半**。
- **为什么旧断言拦不住**:既有跨包断言比的是 **memory 包的常量**(「我打算用多少」),而病害发生在**另一个包**(config 的 tag),单包自洽、跨包漂移看不见。本版新增一条**走真实组装路径**的断言(`LoadMemoryReviewConfig` → `ReviewConfig` → `normalizeReviewConfig` → **生效值**,再断言它 > 实测最坏链长 + T1 子超时),并在注释里写明「**比常量 vs 比生效值**」的区别。
- **兼容性**:**纯行为修复,无 API 形状变化**;`.env` / `.env.example` 文件内容**一字未动**(该键从未进过模板,符合 `.env` 冻结口径);受影响的只有「没显式配这个键」的部署 —— 它们的单条审查预算由 20s 变 90s。
- **诚实边界**:若某部署**显式**写了 `MEMORY_REVIEW_TIMEOUT_SEC=20`,本版**不会**替你抬到 90(那是显式配置,尊重它)。另有两处**散文副本**仍写着旧值(`internal/runtime/memory/preinsert_t1.go` 的一处注释),已 file finding、不在本版改动面内。
### v0.92
**「记忆整理调优键可写」版 —— 管理面新增 2 条 `writeAuth` 端点,把面板从「只能改文件 + 重启」救出来**(依据:主人 2026-10-13 口径「`.env` 只做兜底…这些键优先从面板配置上取」「频率必须调…白天间隔短一点、静默期长一点」;实现切片 M4)
@@ -1787,7 +1799,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **① `graph_update` 按路分桶(切片 FV2)**:帧 `data` 新增**可选** `turnId`(这张图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳同值)与 `round`(会话轮次号);图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ **单路行为逐字不变**),因此真并行时**每一路各发自己的帧、各落一行**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 不再把另一路整行顶掉)。`GET /api/v1/admin/flow/graph` 响应新增 **`lanes[]`**(最近一轮每一路的图 `[{turnId,graph}]`,按路开始时间升序,恒为非 null 数组;`graph` 仍是「最新一路」⇒ **老的读取方零改动**),前端在 ≥2 路时按**并列泳道**呈现 —— **§2.2 B13 / §3.4.5 / §4.11(a)(d) / §7.2 / §7.4-19**。⚠️ 留白:`turns[]` 仍按**路**列条目(一轮两路显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路。
- **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 MT-A)**:每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认 **`20 → 200`**;两两比对上限从**写死的 `400`** 改为 env 键 **`MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`)**;配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(被**墙钟时间预算**截断:整分区跳过,或两两比对中途收手 —— 成因是**本次调用的时间上限**而不是配置配额);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询:并发**复用既有 `runId`**、与同步**同一个 3 分钟**墙钟、`done` 的 `result` 与同步 `data` **逐字节同形**、`running` 时 `result`/`error` **双缺席**、重启后旧 `runId` 如实 `404`)—— **§4.10(a) / §4.12 / §4.12.1 / §5**。
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **默认值已由 v0.93 改为 `90`** —— 键名与文件内容未动,只是代码侧默认值,见 §6 v0.93)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
- **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。
- **破坏性**:无。
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
+17 -6
View File
@@ -10,7 +10,7 @@ import (
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
//
// 四件事只在这里定:
// 六件事只在这里定:
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
@@ -26,7 +26,12 @@ import (
// 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着;
// - **到期清扫的挂点**(2026-10-13 追加,切片 M3,design/23 §19):临时记忆到期
// 后要**真删掉**(读侧时效闸只负责「看不见」)。清扫是 memory 包内的后台件
// (expire.go / expire_loop.go),起在 **TidyService.Start** 里、与心跳共用
// 同一个 ctx 与 wg,因此它跟整理同生共死(`Stop` 一并停掉它),不新开关闭链、
// 不新开端口、不加 env 键 —— 组合根这边**零接线**。
//
// 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
@@ -49,6 +54,10 @@ func (a *app) wireMemoryTidy() error {
if err != nil {
return fmt.Errorf("memory tidy: %w", err)
}
// 到期清扫(design/23 §19 的第 ② / ③ 步)**不在组合根挂**:它起在
// TidyService.Start 里,与整理心跳共用同一个 ctx 与 wg(见 expire_loop.go)。
// 因此这里零改动即完成接线 —— 不新起一条关闭链、不新开监听端口、不加任何
// env 键,`wire_services.go` 的 a.memoryTidy.Stop 会连同它一起停掉。
a.memoryTidy = svc
return nil
}
@@ -58,11 +67,13 @@ func (a *app) wireMemoryTidy() error {
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)**外加**临时
// 记忆 TTL 一键(tempTtlSec,切片 M2 加进来的第五个 review 键)在 `.env` 里
// **都没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的四个 helper:memoryIngestGateOff / memoryT1JudgeOff /
// memoryT1RateLimit / memoryTempTTL),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十一键里,
// 因此不必为它去读一次 env。
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
+12
View File
@@ -69,6 +69,18 @@ func TestMemoryTidyIsWired(t *testing.T) {
"main.go": {
"a.memoryTidy.Start(ctx)",
},
// 到期清扫(切片 M3,design/23 §19 的第 ② / ③ 步):临时记忆到期后要
// **真删掉**,而它起在 TidyService.Start 里(与心跳同一个 cctx / wg)。
// 少了这一句,过期行只会被读侧时效闸「挡在门外」而**永远不删** ——
// 不报错、不红任何别的用例,只是库里的临时层无界留存。因此这里把它
// 钉成 seam:`TestTidyServiceStart_清扫随服务起停` 是它的运行时对照。
"../../internal/runtime/memory/tidy_service.go": {
"go s.expirySweepLoop(cctx)",
},
"../../internal/runtime/memory/expire_loop.go": {
"func (s *TidyService) expirySweepLoop(ctx context.Context)",
"DefaultExpireSweepInterval",
},
"wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.Stop(ctx)",
@@ -9,6 +9,7 @@ import { Button, Surface, Tag } from "../../components/primitives";
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent",
@@ -191,6 +192,8 @@ export function MemoriesModule(): React.ReactNode {
<MemoryTidyApplyCard />
<MemoryTidyTuningCard />
<MemoryTidyFactsCard />
<Notice>
@@ -25,6 +25,8 @@ import {
MemoryTidyDryRunBody,
TIDY_DRY_RUN_DISCLAIMER,
TIDY_DRY_RUN_ERROR_HINT,
TIDY_RECALL_UNAVAILABLE,
TIDY_RECALL_UNAVAILABLE_HINT,
type TidyDryRunState,
} from "./MemoryTidyDryRunCard";
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
const recallCases: Array<[string, string]> = [
["embedding", "语义向量"],
["lexical", "已降级"],
["mixed", "部分分区降级"],
["", "无候选可比"],
["unregistered_mode", "未登记取值"],
];
for (const [mode, want] of recallCases) {
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
}
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
const recallDownHtml = render({
kind: "error",
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
code: TIDY_RECALL_UNAVAILABLE,
});
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
const idleHtml = render({ kind: "idle" });
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
@@ -1,5 +1,5 @@
import { useState } from "react";
import { adminMemoryTidyDryRun } from "../../lib/api";
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
@@ -38,7 +38,18 @@ export type TidyDryRunState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "done"; result: AdminTidyDryRunResult }
| { kind: "error"; message: string };
| { kind: "error"; message: string; code?: string };
/** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。
*
* 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`)
* 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它
* 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */
export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable";
/** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */
export const TIDY_RECALL_UNAVAILABLE_HINT =
"这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。";
const actionLabel: Record<string, string> = {
merge: "并入",
@@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R
);
}
/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。
*
* 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没
* 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会
* 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**):
*
* - `embedding` —— 语义向量召回真的用上了(好态);
* - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少);
* - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增);
* - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成
* 一个空标签 —— 那会让人以为字段坏了)。
*
* ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错
* (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有
* `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许
* 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */
export function recallSourceLabel(mode: string): string {
switch (mode) {
case "embedding":
return "召回来源:语义向量(embedding)";
case "lexical":
return "召回来源:词项余弦(未接向量,已降级)";
case "mixed":
return "召回来源:部分分区降级(其余走向量)";
case "":
return "召回来源:无候选可比(本轮未做召回)";
default:
return `召回来源:${mode}(未登记取值)`;
}
}
/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */
export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" {
switch (mode) {
case "embedding":
return "accent";
case "lexical":
case "mixed":
return "warn";
default:
return "neutral";
}
}
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
const vetoReasons = result.vetoReasons ?? {};
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Tag tone="ok">算完了,一行都没写</Tag>
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
<Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
<span className="text-[11px] text-[var(--mu-text-lo)]">
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
return (
<div className="space-y-1">
<Notice tone="err">试算失败:{state.message}</Notice>
{state.code === TIDY_RECALL_UNAVAILABLE ? (
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
) : null}
<span className="text-[11px] text-[var(--mu-text-lo)]">
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
</span>
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
})
.catch((e: unknown) => {
log("memory.tidy.dryRun", "error");
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
setState({
kind: "error",
message: e instanceof Error ? e.message : "试算失败",
code: e instanceof ApiError ? e.code : undefined,
});
});
};
@@ -0,0 +1,124 @@
// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。
//
// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts):
// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」);
// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」;
// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。
//
// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories";
import {
MemoryTidyTuningBody,
TIDY_TUNING_COLD_LABEL,
TIDY_TUNING_DISCLAIMER,
TIDY_TUNING_HOT_LABEL,
} from "./MemoryTidyTuningCard";
import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */
function fixtureView(overrides: Partial<AdminMemoryTidyTuningView> = {}): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效",
specs: [
{ key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" },
{ key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" },
{ key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" },
{ key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" },
{ key: "ingestGate", kind: "bool", fallback: "code-default" },
{ key: "autoApply", kind: "bool", fallback: "code-default" },
{ key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" },
],
values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true },
envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 },
...overrides,
};
}
function renderBody(view: AdminMemoryTidyTuningView, draft: Record<string, string>, errors: Record<string, string> = {}, extra: Record<string, unknown> = {}): string {
return renderToStaticMarkup(
createElement(MemoryTidyTuningBody, {
view,
draft,
errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => {},
onSave: () => {},
...extra,
} as never)
);
}
const view = fixtureView();
const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } });
// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ───────────────────────────────────
for (const key of Object.keys(TUNING_LABELS)) {
ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`);
ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`);
}
ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」");
ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点");
ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)");
ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来");
ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec");
ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认");
ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)");
// ── ② 热生效与否:两种口径必须分得清 ───────────────────────────────────────
ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」");
ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值");
ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值");
const coldHtml = renderBody(view, draftFromView(view), {}, {
receipt: "已保存:平时整理间隔。",
savedView: view, // hot: false
});
ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」");
ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」");
ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因");
// ── ③ 坏值就地报错并拦住保存 ───────────────────────────────────────────────
const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, {
intervalSec: "平时整理间隔 必须 ≥ 1 秒",
});
ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案");
ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)");
ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用");
ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径");
ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)");
// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。
const missing = fixtureView({ values: {} });
const missingHtml = renderBody(missing, draftFromView(missing));
ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」");
ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)");
// eslint-disable-next-line no-console
console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from "react";
import {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
type AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
import { useAuditStore } from "../audit";
import type { TuningDraft } from "./memoryTidyTuning";
import {
TUNING_LABELS,
buildTuningPatch,
draftFromView,
fallbackLabel,
patchSummary,
secondsHint,
unitLabel,
} from "./memoryTidyTuning";
/**
* 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go。
*
* 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件
* 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时
* 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。
* 每个键都写清「跟随谁」,不做含糊的「未设置」;
* 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿
* **不发请求**(后端那道 400 仍在,只是不让主人白跑);
* 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成
* 「已生效」。
*
* 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件
* 负责取数 + 表单草稿 + 保存。
*/
/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */
export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底";
/** 热生效与「要重启」两种文案(测试分别钉住)。 */
export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)";
export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效";
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface TuningBodyProps {
view: AdminMemoryTidyTuningView;
draft: TuningDraft;
errors: Record<string, string>;
busy: boolean;
/** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */
receipt: string | null;
/** 保存后回显的真值(后端**重新读盘**的结果)。 */
savedView: AdminMemoryTidyTuningView | null;
onDraftChange: (key: string, value: string) => void;
onSave: () => void;
}
/** hotLabel 返回「这次到底生没生效」的诚实文案。 */
export function hotLabel(view: AdminMemoryTidyTuningView): string {
return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL;
}
/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */
function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string {
const envValue = view.envValues?.[key];
const base = fallbackLabel({ key, kind: "int", fallback });
return envValue === undefined ? base : `${base}(当前 ${envValue})`;
}
/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */
function FieldRow({
spec,
value,
error,
fallback,
valueHint,
onChange,
}: {
spec: AdminMemoryTidyTuningView["specs"][number];
value: string;
error: string;
fallback: string;
valueHint: string;
onChange: (next: string) => void;
}): React.ReactNode {
const label = TUNING_LABELS[spec.key] ?? spec.key;
const inputCls =
"w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{label}</span>
{spec.kind === "bool" ? (
<select
value={value}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onChange(e.target.value)}
>
<option value="">跟随兜底</option>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={value}
inputMode="numeric"
placeholder="留空 = 跟随兜底"
className={inputCls}
onChange={(e) => onChange(e.target.value)}
/>
)}
{valueHint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{valueHint}</span> : null}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{spec.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">
{value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`}
{spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""}
</div>
{error ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {error}</div> : null}
</div>
);
}
/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function MemoryTidyTuningBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: TuningBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{TIDY_TUNING_DISCLAIMER}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"}
</span>
</div>
{view.specs.map((spec) => (
<FieldRow
key={spec.key}
spec={spec}
value={draft[spec.key] ?? ""}
error={errors[spec.key] ?? ""}
fallback={fallbackHint(view, spec.key, spec.fallback)}
valueHint={spec.unit === "sec" && /^\d+$/.test((draft[spec.key] ?? "").trim())
? secondsHint(Number((draft[spec.key] ?? "").trim()))
: ""}
onChange={(next) => onDraftChange(spec.key, next)}
/>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存调优"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(从磁盘重新读回的真值):</span>
{savedView.specs.map((spec) => {
const v = savedView.values[spec.key];
return (
<div key={spec.key} className="font-mono text-[var(--mu-text-lo)]">
{spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)}
</div>
);
})}
<div className="text-[var(--mu-text-mid)]">{hotLabel(savedView)}</div>
{savedView.hot ? null : (
<div className="text-[var(--mu-text-lo)]">{savedView.hotError ?? "运行期未接线可热生效的实现"}</div>
)}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。
清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。
</p>
</div>
);
}
/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */
export function MemoryTidyTuningCard(): React.ReactNode {
const [view, setView] = useState<AdminMemoryTidyTuningView | null>(null);
const [draft, setDraft] = useState<TuningDraft>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<AdminMemoryTidyTuningView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return adminMemoryTidyTuning()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: string, value: string) => {
setDraft((prev) => ({ ...prev, [key]: value }));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view) return;
const check = buildTuningPatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
setBusy(true);
setFail(null);
adminMemoryTidyTuningSave(check.patch)
.then((next) => {
// 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`);
log("memory.tidy.tuning", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存面板配置失败");
log("memory.tidy.tuning", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">记忆整理调优(面板配置)</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/memories/tidy/tuning</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<MemoryTidyTuningBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -0,0 +1,167 @@
// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。
//
// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 /
// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者
// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里,
// 因此不需要渲染就能钉住。
//
// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import type {
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import {
buildTuningPatch,
draftFromView,
fallbackLabel,
fieldError,
patchSummary,
secondsHint,
} from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`);
}
function deepEq(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
checks += 1;
if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`);
}
/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */
function spec(key: string, kind: string, extra: Partial<AdminMemoryTidyTuningSpec> = {}): AdminMemoryTidyTuningSpec {
return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec;
}
/** view 造一份 GET 响应形状的 fixture。 */
function view(values: Record<string, number | boolean>, envValues?: Record<string, number>): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现",
specs: [
spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }),
spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }),
spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }),
spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }),
spec("ingestGate", "bool", { fallback: "code-default" }),
spec("autoApply", "bool", { fallback: "code-default" }),
spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }),
],
values,
envValues,
};
}
// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。
{
const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false }));
eq(draft.intervalSec, "10800", "草稿回填盘上值");
eq(draft.ingestGate, "false", "布尔键回填为字符串");
eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
}
// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。
{
const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false });
const draft = draftFromView(base);
const untouched = buildTuningPatch(base, draft);
deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)");
deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误");
const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" });
deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)");
deepEq(changed.errors, {}, "合法值 ⇒ 无错误");
}
// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底);
// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。
{
const base = view({ intervalSec: 3600, tempTtlSec: 604800 });
const draft = draftFromView(base);
const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" });
deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)");
const alreadyEmpty = view({ ingestGate: true });
const mix = view({ ingestGate: true });
const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" });
deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)");
}
// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。
{
const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 });
const draft = draftFromView(base);
const cases: Array<[string, string, string]> = [
["intervalSec", "abc", "非整数"],
["intervalSec", "0", "低于下界(0 会让整理空转)"],
["intervalSec", "-60", "负数"],
["quietStartHour", "24", "钟点越上界"],
["quietEndHour", "-1", "钟点越下界"],
["tempTtlSec", "1.5", "小数不是整数"],
];
for (const [key, raw, why] of cases) {
const res = buildTuningPatch(base, { ...draft, [key]: raw });
ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`);
ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`);
}
// 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。
const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" });
ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处");
eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)");
// 布尔键只认三个取值。
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错");
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法");
}
// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。
{
eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法");
eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法");
eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法");
}
// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。
{
eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })),
"跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案");
eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })),
"跟随面板里的 intervalSec", "面板内兜底文案");
eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案");
eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示");
eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示");
eq(secondsHint(0), "", "非正数不提示");
eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案");
eq(patchSummary({}), "没有需要改的键", "空补丁回执");
}
// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。
{
const base = view({ intervalSec: 3600 });
const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " });
deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍");
const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" });
ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数");
}
console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);
@@ -0,0 +1,152 @@
import type {
AdminMemoryTidyTuningPatch,
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
/* ----------------------------------------------------------------------------
* 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。
*
* 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接
* 用 node 跑断言(见 memoryTidyTuning.test.ts)。
*
* 三条不可走样的口径:
* 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套
* 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把
* spec 翻成**草稿的校验规则**与中文文案。
* 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本
* 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果
* 完全不同,因此这里必须分开。
* 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等
* 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。
* -------------------------------------------------------------------------- */
/** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */
export const TUNING_LABELS: Record<string, string> = {
intervalSec: "平时整理间隔",
quietIntervalSec: "静默时段整理间隔",
quietStartHour: "静默时段开始(本地钟点)",
quietEndHour: "静默时段结束(本地钟点)",
ingestGate: "同步入库闸(写入时当场审查)",
autoApply: "轮末自动落地(高置信候选直接改记忆)",
tempTtlSec: "临时记忆存活时长",
};
/** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */
export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string {
if (spec.fallback.startsWith("env:")) {
return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`;
}
if (spec.fallback.startsWith("panel:")) {
return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`;
}
return "跟随代码默认";
}
/** 单位的中文说明。 */
export function unitLabel(spec: AdminMemoryTidyTuningSpec): string {
if (spec.unit === "sec") return "秒";
if (spec.unit === "hour") return "本地钟点(0-23)";
return "";
}
/** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */
export function secondsHint(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "";
if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`;
if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`;
return `≈ ${seconds} 秒`;
}
/** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */
export type TuningDraft = Record<string, string>;
/** 草稿的校验结果。 */
export interface TuningDraftCheck {
/** 键 → 就地错误文案(空对象 = 可以保存)。 */
errors: Record<string, string>;
/** 真正要提交的三态补丁(只含改动过的键)。 */
patch: AdminMemoryTidyTuningPatch;
}
/** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */
export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft {
const draft: TuningDraft = {};
for (const spec of view.specs) {
const value = view.values[spec.key];
if (value === undefined || value === null) {
draft[spec.key] = "";
continue;
}
draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value);
}
return draft;
}
/** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */
export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string {
const text = raw.trim();
if (text === "") return "";
if (spec.kind === "bool") {
return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`;
}
if (!/^-?\d+$/.test(text)) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`;
}
const n = Number(text);
if (spec.min !== undefined && n < spec.min) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`;
}
if (spec.max !== undefined && n > spec.max) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`;
}
return "";
}
/** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */
export function buildTuningPatch(
view: AdminMemoryTidyTuningView,
draft: TuningDraft
): TuningDraftCheck {
const errors: Record<string, string> = {};
const patch: AdminMemoryTidyTuningPatch = {};
for (const spec of view.specs) {
const raw = draft[spec.key] ?? "";
const err = fieldError(spec, raw);
if (err) {
errors[spec.key] = err;
continue;
}
const current = view.values[spec.key];
const text = raw.trim();
if (text === "") {
// 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。
if (current === undefined || current === null) continue;
// 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。
patch[spec.key] = null;
continue;
}
if (spec.kind === "bool") {
const next = text === "true";
if (typeof current === "boolean" && current === next) continue;
patch[spec.key] = next;
continue;
}
const next = Number(text);
if (typeof current === "number" && current === next) continue;
patch[spec.key] = next;
}
return { errors, patch };
}
/** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */
export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] {
return Object.keys(patch).sort();
}
/** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */
export function patchSummary(patch: AdminMemoryTidyTuningPatch): string {
const keys = patchKeys(patch);
if (keys.length === 0) return "没有需要改的键";
return keys.map((k) => TUNING_LABELS[k] ?? k).join("、");
}
+9
View File
@@ -155,3 +155,12 @@ export {
adminTidyCandidatesApplyBatch,
adminTidyRun,
} from "./api/adminMemoryTidyApply";
export {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
} from "./api/adminMemories";
export type {
AdminMemoryTidyTuningPatch,
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "./api/adminMemories";
+75
View File
@@ -0,0 +1,75 @@
import { request } from "./core";
/* ----------------------------------------------------------------------------
* 记忆整理**面板调优**(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go(形状与错误码)
* internal/services/gateway/admin_memory_tuning_file.go(取值规则 specs)
*
* GET /admin/memories/tidy/tuning 读:盘上 7 键值 + 取值规则
* PUT /admin/memories/tidy/tuning 写:三态补丁(键缺位 = 不改动 / 合法值 = 采用 /
* 非法值 = 400 且盘上不动;`null` = 清掉该键)
*
* 两条都是 writeAuth(owner|admin)——与同族的 `/admin/memories` 同档:它是私有记忆面
* 的运行期旋钮,不是给 viewer 的监控投影。
*
* ⚠️ 失败走 `error.code`,**绝不**回一份「已保存」的假成功:
* - `invalid_payload` 400:值非法 / 未知键(**盘上一个字节都没动**)
* - `tuning_corrupt` 409:盘上那份配置文件读不走 ⇒ 拒绝覆盖(要人工修/删)
* - `tuning_unavailable` 500:数据目录未接线
* - `tuning_write_failed` 500:落盘失败(盘上内容未改动)
*
* 响应里的 `hot` 是**如实**的:true = 保存后当场生效;false = 已落盘,但运行期没接到
* 可热生效的实现(`hotError` 说明原因),此时界面必须写「重启后端后生效」。
* -------------------------------------------------------------------------- */
/** 调的落点(面板配置文件的**相对**路径,形如 `config/memory-tidy.json`)。 */
const TUNING_PATH = "/admin/memories/tidy/tuning";
/** 一个可写键的取值规则(后端下发,面板不另抄一份枚举/上下界)。 */
export interface AdminMemoryTidyTuningSpec {
/** 键名,如 `intervalSec` / `ingestGate`。 */
key: string;
/** `int` | `bool`(面板据此选控件类型)。 */
kind: string;
/** `sec` | `hour`(仅 int 键;面板据此写单位)。 */
unit?: string;
min?: number;
max?: number;
/** 缺位时生效值从哪来:`env:MEMORY_TIDY_*` / `panel:<key>` / `code-default`。 */
fallback: string;
}
/** GET / PUT 的 data(PUT 的响应是**重新读盘**的结果,即真值回显)。 */
export interface AdminMemoryTidyTuningView {
/** 面板配置文件相对 `$DATA_DIR` 的路径。 */
path: string;
/** 文件是否已存在且有内容(false = 还没播种,后端下次启动会自动创建)。 */
present: boolean;
/** 本次结果是否**当场生效**。false = 已落盘,要等后端重启(见 hotError)。 */
hot: boolean;
/** `hot:false` 时的原因。 */
hotError?: string;
specs: AdminMemoryTidyTuningSpec[];
/** **盘上**的值:键缺位 ⇒ 该键不出现(= 跟随兜底),不是 0。 */
values: Record<string, number | boolean>;
/** 缺位时**下一档(`.env`)**的进程生效值(与 /admin/config-facts 同源)。 */
envValues?: Record<string, number>;
}
/** 写补丁:只放**真的要改**的键;`null` = 清掉该键(回到下一档兜底)。 */
export type AdminMemoryTidyTuningPatch = Record<string, number | boolean | null>;
/** GET /api/v1/admin/memories/tidy/tuning —— 读盘上的调优值 + 取值规则。 */
export function adminMemoryTidyTuning(): Promise<AdminMemoryTidyTuningView> {
return request<AdminMemoryTidyTuningView>(TUNING_PATH);
}
/** PUT /api/v1/admin/memories/tidy/tuning —— 三态补丁写盘(并尽量热生效)。 */
export function adminMemoryTidyTuningSave(
patch: AdminMemoryTidyTuningPatch
): Promise<AdminMemoryTidyTuningView> {
return request<AdminMemoryTidyTuningView>(TUNING_PATH, {
method: "PUT",
body: JSON.stringify(patch),
});
}
+27 -2
View File
@@ -12,7 +12,8 @@ import (
// 为什么用独立加载器而不是挂进 Config 根结构:Config 由 Load 统一解码,而
// 本切片刻意**不碰** Config / 记忆整理那组键(并行切片正在改它们)。这里复用
// 与 LoadLLMDefaults 同一手法(parseDotEnv + 同一份 `env:"…"` 解码器),
// 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值。
// 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值(TimeoutSec
// 没有标签默认值,见该字段的注释 —— 未配置时它读出 0,由 memory 包定其生效值)。
// envPath 为空表示不看 `.env`(与 Load 的约定一致)。
type MemoryReviewConfig struct {
// Enabled 是入库前审查总开关,默认 true。依据:主人 2026-10-05 的裁定
@@ -28,12 +29,36 @@ type MemoryReviewConfig struct {
ScanLimit int `env:"MEMORY_REVIEW_SCAN_LIMIT" default:"200"`
// TimeoutSec 是单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**,
// 记忆照旧保留 + WARN(design/23 §15 D3-5 的 fail-safe)。
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC" default:"20"`
//
// 🔴 **本字段刻意没有 `default:` 标签**(2026-10-13,切片 M16)。单条审查预算的
// **唯一真源**是 memory 包的 `defaultReviewTimeout`(90s,推导见
// internal/runtime/memory/preinsert_config.go 的文件头注释)。这里原写
// `default:"20"`,后果不是「默认值是 20」而是**默认值根本不由 memory 侧决定**:
// env 层永远产出一个非零值 ⇒ memory 侧那道「≤0 才回落默认」的归一化成了**死代码**
// (90s 从未生效,生产启动行 `memory ingest review started … timeout=20s` 即铁证,
// 而实测召回链就要 21~23s ⇒ 每条候选满额的审查都超时、连留痕都不写)。
//
// 去掉字面默认后的**生效值**(路径:本字段 → cmd/cyrene/wire_memory_review.go 的
// `ReviewConfig.Timeout` → `normalizeReviewConfig`):
//
// `.env` / 进程环境没写这个键 ⇒ 这里 0 ⇒ memory 侧回落 90s
// 写了正数 ⇒ 原样生效(归一化只动 ≤0)
// 写了 0 / 负数 ⇒ 回落 90s(与项目其它旋钮同一口径:≤0 = 用默认)
//
// ⚠️ 别在这里重新加回字面默认值 —— 那会让 memory 侧的 90s 再次变成死代码,
// 而 memory 包的 TestReviewTimeoutEffectiveValueIsTheOnlyDefault 会立刻变红
// (它走完整组装路径取生效值,不比对常量)。
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC"`
}
// LoadMemoryReviewConfig 读取入库前审查的旋钮。非法(负数)值不在这里报错,
// 而是与项目其它旋钮同一口径 —— **≤0 视为「用默认」**,由 memory 包 normalize
// 收敛,避免一个笔误让进程起不来。
//
// 🔴 因此本函数**对 TimeoutSec 不做任何回落**(切片 M16):返回 0 就是「没配」,
// 调用方必须原样交给 memory 包的 ReviewConfig(组合根今天就是这么做的),
// 「0 ⇒ 多少秒」由 `normalizeReviewConfig` 在那**唯一一处**决定。调用方若在这里
// 又补一个自己的默认值,全链就会重新长出第二个真源 —— 那正是本切片修掉的形态。
func LoadMemoryReviewConfig(envPath string) (MemoryReviewConfig, error) {
fromEnv := map[string]string{}
if envPath != "" {
+60 -2
View File
@@ -1,13 +1,47 @@
package config
import (
"os"
"testing"
)
// reviewTimeoutEnvUnset 让 MEMORY_REVIEW_TIMEOUT_SEC 在本次用例中**真的未设置**。
//
// 为什么不用 t.Setenv(key, ""):空串在 decode.go 的 lookupRaw 里**照样命中 env 分支**
// (`os.LookupEnv` 对空值返回 ok=true)⇒ 配置层读出 0,于是**看不见 tag 上的字面
// 默认值** —— 而那正是本切片的回归面(tag 上再加回 `default:"20"` 必须变红)。
// 未设置才会走到 tag 默认那一档。
func reviewTimeoutEnvUnset(t *testing.T) {
t.Helper()
old, had := os.LookupEnv("MEMORY_REVIEW_TIMEOUT_SEC")
t.Cleanup(func() {
if had {
_ = os.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", old)
return
}
_ = os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC")
})
if err := os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC"); err != nil {
t.Fatalf("unset MEMORY_REVIEW_TIMEOUT_SEC: %v", err)
}
}
// TestLoadMemoryReviewConfigDefaults 锁定 D3「入库前审查」(design/23 §15.1 D3)
// 的 env 契约默认值。它们必须与 memory 包的兜底默认值一致 —— 两处不一致会让
// 「没配这个键」与「配了但解析失败」走向不同的行为。
//
// 🔴 TimeoutSec 是**例外的那一个,而且是刻意的**(切片 M16,2026-10-13):
// 它**没有**字面默认值,未配置时就是 0,含义是「没配」而不是「0 秒」。
// 单条审查预算的唯一真源在 memory 包的 `defaultReviewTimeout`(90s),
// 全链的生效值 = `ReviewConfig.Timeout` 经 `normalizeReviewConfig` 归一化之后的值。
//
// 为什么不在这里断言 90:那会让本仓重新长出**两个**真源(env tag 一处、
// memory 常量一处),而 `.env` 冻结口径(AGENTS §10)本来就把这类「上限/调优」
// 参数指向**代码默认值**。断言生效值的那条测试在 memory 包
// (TestReviewTimeoutEffectiveValueIsTheOnlyDefault,走完整组装路径)——
// 本包不能引 memory 包(memory → config 已成立,反向会成环)。
func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
reviewTimeoutEnvUnset(t)
cfg, err := LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig() error: %v", err)
@@ -21,13 +55,19 @@ func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
if cfg.ScanLimit != 200 {
t.Errorf("MEMORY_REVIEW_SCAN_LIMIT = %d, want 200(§15 D3-4 每条审查有界)", cfg.ScanLimit)
}
if cfg.TimeoutSec != 20 {
t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 20(§15 D3-5 超时即不改写)", cfg.TimeoutSec)
if cfg.TimeoutSec != 0 {
t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 0(未配置)—— "+
"这里出现非零值说明 tag 上又被加回了字面默认值,memory 侧的 "+
"defaultReviewTimeout 会再次变成死代码(M16 修的正是这个形态)", cfg.TimeoutSec)
}
}
// TestLoadMemoryReviewConfigEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的
// 生效(键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。
//
// TimeoutSec 另钉一条口径(M16):**正数原样透传、≤0 原样透传**。配置层不做
// 「坏值回落」——「0 或负数 ⇒ 用默认 90s」是 memory 包 normalizeReviewConfig
// 的语义,全链只有那一处决定它(组合根不许在这里补默认值)。
func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
t.Setenv("MEMORY_REVIEW_ENABLED", "false")
t.Setenv("MEMORY_REVIEW_QUEUE_SIZE", "8")
@@ -41,4 +81,22 @@ func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
if cfg.Enabled || cfg.QueueSize != 8 || cfg.ScanLimit != 32 || cfg.TimeoutSec != 3 {
t.Fatalf("MEMORY_REVIEW_* 未生效:%+v", cfg)
}
for _, tc := range []struct {
raw string
want int
}{
{"0", 0}, // 显式 0 = 用默认(下游归一化),不许在这里被改成 90
{"-5", -5}, // 负数同样是「用默认」,也不在这里报错(AGENTS:不让笔误把进程弄起不来)
} {
t.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", tc.raw)
got, err := LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig(%q) error: %v", tc.raw, err)
}
if got.TimeoutSec != tc.want {
t.Fatalf("MEMORY_REVIEW_TIMEOUT_SEC=%q 读出 %d, want %d(配置层不回落,回落只在 memory 包)",
tc.raw, got.TimeoutSec, tc.want)
}
}
}
@@ -0,0 +1,150 @@
// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。
//
// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次
// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板
// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短
// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径**
// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。
//
// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy
// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。
//
// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法):
//
// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份
// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样
// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。
// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent
// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的
// 手写排版不该因为一次空操作被重排。
// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回
// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体
// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。
// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、
// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个
// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。
//
// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成
// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生
// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。
package runtimeconfig
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。
memoryTidySaveFileMode = 0o600
// memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。
memoryTidySaveDirMode = 0o755
)
// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进
// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。
//
// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API
// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉
// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。
//
// 返回 (tuning, wrote, err):
// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」);
// - wrote 报告这次是否真的写了字节(内容语义未变时为 false);
// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示
// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。
func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) {
doc, err := readMemoryTidyDocForPatch(path)
if err != nil {
return MemoryTidyTuning{}, false, err
}
before := canonicalMemoryTidyJSON(doc)
merged := make(map[string]json.RawMessage, len(doc)+len(set))
for key, value := range doc {
merged[key] = value
}
for key, value := range set {
merged[key] = value
}
for _, key := range clear {
delete(merged, key)
}
raw, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err)
}
wrote := false
if !bytes.Equal(raw, before) {
if err := writeMemoryTidyTuningFile(path, raw); err != nil {
return MemoryTidyTuning{}, false, err
}
wrote = true
}
tuning, err := LoadMemoryTidyTuning(path)
if err != nil {
// 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取
// 路径读走),如实报错,绝不回一份「成功」。
return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err)
}
return tuning, wrote, nil
}
// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。
//
// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误);
// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy
// (口径 3:拒绝覆盖)。
func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]json.RawMessage{}, nil
}
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return map[string]json.RawMessage{}, nil
}
// 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事
// 就是自相矛盾(写入方会据此把一份坏配置盖掉)。
if _, err := decodeMemoryTidy(raw, path); err != nil {
return nil, err
}
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err)
}
return doc, nil
}
// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径),
// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等
// (= 走正常落盘路径),不会误判成「没变」。
func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte {
raw, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil
}
return raw
}
// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。
func writeMemoryTidyTuningFile(path string, raw []byte) error {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil {
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
}
return nil
}
@@ -0,0 +1,151 @@
package runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
)
// 本文件锁定 `memory-tidy.json` **写路径**(PatchMemoryTidyTuning)的四条口径:
// ① 补丁语义:只改提交的键,盘上其它键(t1Judge 等)**原样保留**;
// ② 值没变 ⇒ 一个字节都不写(连 mtime 都不动);
// ③ 盘上那份**读不走**时拒绝覆盖(ErrInvalidMemoryTidy),且**盘上不动**;
// ④ 原子写:temp + rename,成功之后不留 `.tmp`,文件权限 0600(Windows 不校验)。
// patchTuning 是测试用的补丁构造:键 → 原样 JSON 值。
func patchTuning(t *testing.T, pairs map[string]any) map[string]json.RawMessage {
t.Helper()
out := make(map[string]json.RawMessage, len(pairs))
for k, v := range pairs {
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal %s: %v", k, err)
}
out[k] = raw
}
return out
}
// TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys 补丁只动提交的键,其余键原样保留;
// 返回值是**重新读盘**的结果(真值),不是「我以为写成了什么」。
func TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"t1Judge": false, "t1PerWindow": 120, "intervalSec": 3600}`)
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 7200}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if !wrote {
t.Fatal("值变了却没写盘")
}
if tuning.IntervalSec == nil || *tuning.IntervalSec != 7200 {
t.Fatalf("返回值不是读回的真值:%+v", tuning.IntervalSec)
}
if tuning.T1Judge == nil || *tuning.T1Judge {
t.Fatalf("没提交的 t1Judge 被动了:%+v", tuning.T1Judge)
}
doc := map[string]json.RawMessage{}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if string(doc["intervalSec"]) != "7200" || string(doc["t1PerWindow"]) != "120" {
t.Fatalf("盘上内容 = %s", raw)
}
if string(doc["t1Judge"]) != "false" {
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", raw)
}
// 清除哨兵:clear 里的键从盘上消失(= 回到下一档兜底)。
if _, _, err := PatchMemoryTidyTuning(path, nil, []string{"intervalSec"}); err != nil {
t.Fatalf("clear: %v", err)
}
doc = map[string]json.RawMessage{}
raw, _ = os.ReadFile(path)
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := doc["intervalSec"]; ok {
t.Fatalf("clear 之后盘上仍有 intervalSec:%s", raw)
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("clear 抹掉了别的键:%s", raw)
}
}
// TestPatchMemoryTidyTuning_NoChangeWritesNothing 值没变 ⇒ 不写盘、不重排格式。
func TestPatchMemoryTidyTuning_NoChangeWritesNothing(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"intervalSec": 3600}`)
before, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if wrote {
t.Fatal("值没变却写了盘")
}
after, _ := os.ReadFile(path)
if string(after) != string(before) {
t.Fatalf("值没变却改了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
if tuning.IntervalSec == nil || *tuning.IntervalSec != 3600 {
t.Fatalf("没写盘也要回读真值:%+v", tuning.IntervalSec)
}
}
// TestPatchMemoryTidyTuning_RefusesUnreadableFile 盘上那份运行期读不走时拒绝覆盖:
// 返回 ErrInvalidMemoryTidy(调用方据此回 409),且**盘上一个字节都不动**。
func TestPatchMemoryTidyTuning_RefusesUnreadableFile(t *testing.T) {
t.Parallel()
for _, body := range []string{`{"intervalSec": "3600"}`, `not json at all`, `[1, 2]`} {
body := body
t.Run(body, func(t *testing.T) {
path := writeMemoryTidyFile(t, body)
_, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
if !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("err = %v, want ErrInvalidMemoryTidy", err)
}
if wrote {
t.Fatal("读不走的文件被写入了")
}
raw, _ := os.ReadFile(path)
if string(raw) != body {
t.Fatalf("坏文件被改动了:%s", raw)
}
})
}
}
// TestPatchMemoryTidyTuning_CreatesMissingFileAtomically 文件缺失 ⇒ 建目录并原子写出
// (temp + rename):成功之后不留 `.tmp`,权限 0600(Windows 不校验)。
func TestPatchMemoryTidyTuning_CreatesMissingFileAtomically(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", memoryTidyFileName)
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"tempTtlSec": 604800}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if !wrote {
t.Fatal("缺失文件时应当写出")
}
if tuning.TempTtlSec == nil || *tuning.TempTtlSec != 604800 {
t.Fatalf("回读真值 = %+v", tuning.TempTtlSec)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("原子写留下了临时文件:err=%v", err)
}
if info, err := os.Stat(path); err != nil {
t.Fatalf("stat: %v", err)
} else if info.Mode().Perm() != memoryTidySaveFileMode && runtime.GOOS != "windows" {
t.Fatalf("文件权限 = %v, want %v", info.Mode().Perm(), memoryTidySaveFileMode)
}
}
+26 -9
View File
@@ -42,16 +42,27 @@ const (
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
//
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 90s
// (memory.defaultReviewTimeout),而**一条召回链是几十个请求**(201 条 ÷ 8 条/片
// = 26 个,见 budget.go 文件头的链路实测)—— 一次卡死的 HTTP 不能把整条审查的预算
// 都赌掉:把它压在 5s,最坏是**这一片超时**;而 `Embed` 的语义是**任一片失败 ⇒
// 整条调用失败**(其余分片已经算完的工作量一并丢弃),调用方据此按**既有语义**降级
// (召回降级到词项余弦并如实留痕 degraded)——**记忆一条不丢、判定规则一字不改**,
// 变的是这一条审查的召回质量。本常量只砍等待时间,不改任何判定规则。
// (同一口径见 budget.go 文件头 ④ 末段。)
//
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
// 5s 保持不变的依据:本次故障(2026-10-13)的成因**不是**这个数太小(链条是
// 「N 条 → ⌈N/批次⌉ 个请求」,链长超的是**调用方**预算),因此修法是把链条收短
// (budget.go: 批上限 4 → 8)并**单独**抬高调用方预算(memory.defaultReviewTimeout
// 20s → 90s),而不是把单次超时放大到迁就链条 —— 后者会让真挂死的请求也一起
// 拖住整轮审查。
//
// ⚠️ 已知残余(见 budget.go 文件头 ④):空闲窗口实测单请求最大 1.782s,但尖峰会越过
// 本超时 —— **至少存在一个与批次无关的外部/争用分量**(实测 4 条/片也能到 7.139s);
// 越界的**频率**是否随批次上升,本组样本(1/40 vs 2/40)不足以判定,**归因未定**。
// 后果:那一片超时 ⇒ **整条 `Embed` 失败**、其余分片已算完的工作量丢弃 ⇒ 调用方按
// 既有语义降级(召回回落词项余弦 + 留痕 degraded)—— 记忆不丢、判定规则不改。
// 真正要治的是链路对单点慢分片的韧性(重试/分片级容错),不在这里。
//
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
@@ -161,6 +172,12 @@ func (e *APIEmbedder) Dim() int {
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
//
// ⚠️ 代价在调用方那一侧:**一次 Embed(N) 是 ⌈N/条数上限⌉ 个请求**(改前 201 条 =
// 51 个;改后 8 条/片 = 26 个),整条链的墙钟实测 ~21s(见 budget.go 文件头)。
// 因此调用方的**总**预算必须按链长算,而不是按一次请求算 ——
// memory.defaultReviewTimeout = 90s 就是这条口径(20s 的旧值比链长还短,
// 让每一条候选数满额的审查都必然超时,且失败时不留痕)。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 {
return []Vector{}, nil
+6 -1
View File
@@ -216,7 +216,12 @@ func TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer(t *testing.T) {
// 权威定义在 internal/runtime/memory/preinsert_config.go 的 defaultReviewTimeout。
// 跨包的真实断言在 memory 包(preinsert_recall_test.go)里,这里钉住同一个口径,
// 免得 embedding 侧悄悄把超时调到 ≥ 审查预算。
const reviewBudgetRef = 20 * time.Second
//
// 2026-10-13 由 20s 改为 90s:审查的召回链是 201 条 ⇒ ⌈201/8⌉ = 26 个 embedding
// 请求,整条链实测 21.4~21.6s(空闲)—— **20s 的旧值比链长还短**,于是每一条候选
// 满额的审查都必然超时(这是「92% 的审查 recall_mode 为空」的根因)。本文件钉住的
// 是同一个数,不是另一个数:embedding 侧任何一次调用的超时都必须严格小于它。
const reviewBudgetRef = 90 * time.Second
// TestDefaultRequestTimeout 钉住单次 embedding 调用的超时值,并断言它**严格小于**
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
+121 -29
View File
@@ -12,36 +12,93 @@ import "time"
//
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
//
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的
// 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半。⚠️ 这条不等式的**地位**
// 必须读准:它是**稳态、空闲机器上的建模拟合**,用途是**构造批形状**(批次上限由解它
// 得到,见 DefaultBatchLimit / DefaultBatchChars),**不是**「单次请求 < 超时」的延迟
// 保证 —— 文件头 ④ 的实测尖峰(4 条 7.139s / 8 条 9.711s)整段越过 5s,2.5s 的
// 「另一半」装不下它。真正的兜底是三件东西、缺一不可:**调用方预算**
// (memory.defaultReviewTimeout = 90s,按整条链而不是单片算)、**降级路径**
// (任一片失败 ⇒ 调用方回落词项余弦 + 留痕 degraded,记忆不丢、规则不改),以及已登记
// 的**链路韧性遗留**(一片失败丢整条 ⇒ 修法是重试/分片级容错,不在这里)。
//
// ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**;
// 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止
// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源:
// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。
//
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 +
// Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库
// data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏)
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp(PID 318144,起于
// 10-07,**排除冷启动**)+ Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,
// **服务器空闲**,正文取自活库 data/cyrene.db 的 cyrene/user 分区:本次样本
// 201 条、均长 34.7 汉字、最长 75 汉字)
//
// n=1 短 0.066~0.185s 长 0.740s
// n=8 短 0.86~1.11s
// n=12 短 1.27s 长 5.47s
// n=16 短 2.20~3.31s
// n=64 短 7.49~8.49s(改前的批次上限)
// ① 单次请求的**形状曲线**(隔离测量 = 一条请求发完算完,同档重复 2~3 次):
//
// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms,
// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s),
// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。
// n=1 15 字 0.069 / 0.076 / 0.072 s
// n=4 78 字 0.243 / 0.224 / 0.221 s
// n=8 184 字 0.550 / 0.570 / 0.569 s
// n=12 331 字 0.996 / 1.017 s
// n=16 464 字 1.385 / 1.394 / 1.353 s
// n=32 991 字 2.884 s
// n=64 1997 字 6.872 s
//
// 拟合:耗时 ≈ 固定开销 + 条数 × 每条成本;固定开销实测约 50ms、每条成本
// 70~110ms。64 条 6.9s **已越过单次超时 5s**(旧批次上限正落在这里)。
//
// ② **真实链路的墙钟与请求数**:把一条入库审查的召回链(201 条 = 新条 1 +
// ScanLimit 200,preinsert_recall.go 的 embedReviewTexts)按批次形状整条发出:
//
// 形状 请求数 墙钟(s) 单请求均值(s) 单请求最大(s)
// 4 条/片(改前) 51 23.070 / 21.860 0.452 / 0.429 1.449 / 1.052
// 8 条/片(本次) 26 21.435 / 21.597 0.824 / 0.831 1.782 / 1.732
// 16 条/片 / 480 字 16 21.477 → 49.433 1.342 → 3.089 2.119 → **11.043**
// 16 条/片 / 不限字 13 21.012 / 21.904 1.616 / 1.685 2.313 / 2.582
//
// 三条结论 —— 本次修复的判断全部落在这里:
//
// - **链路墙钟由「条数 × 每条成本」主导,几乎与批次形状无关**:同样 201 条,
// 4 条/片与 8 条/片的墙钟都是 ~21s。抬高批次上限买到的是**请求数减半**
// (51 → 26)与其带来的服务端排队压力下降,**不是**链路变快。要让链路落回
// 调用方预算,只有把预算抬到链长之上(审查侧 defaultReviewTimeout)。
// - **8 条/片在空闲实测下不越界**:三轮最大单请求 1.782s,远在 5s 单次超时之内。
// ⚠️ 但这**不是**延迟保证 —— 负载下仍偶发越界(见 ④),尖峰与批次的关系未定。
// - **16 条/片会越界**:同一批正文、同一次会话里复现出单请求 **11.043s**
// (那一轮整条链 49.4s);③ 里真实代码路径 3 次有 2 次越过 5s。批上限取 8 而不是
// 12/16,依据就是这两条**实测**(隔离测量里 16 条三次稳定在 1.35~1.39s ⇒ 这个形态
// 方差极大,不能只看隔离值),**不依赖**任何关于尖峰成因的推断。
//
// ③ **真实代码路径**(不是直连端点:跑 APIEmbedder.Embed(201 条) 本身,用注入的
// RoundTripper 数真实 HTTP 请求数):
//
// 8 条/片 26 个请求 / 201 个向量,墙钟 21.489s、28.220s(两轮,均未触发单请求超时)
// 16 条/片 直接 embedChunk:1.818s、**5.016s 超时**、**5.001s 超时**(3 次里 2 次越界)
//
// ④ **必须登记的残余(实测,不是推测)**:尖峰会越过 5s 单次超时 —— 交错对比
// 40 次 4 条 + 40 次 8 条(同一批正文):
//
// 4 条/片 p50 0.456s p90 1.008s 最大 **7.139s**(>5s 计 1/40)
// 8 条/片 p50 0.848s p90 1.861s 最大 **9.711s**(>5s 计 2/40)
//
// 该窗口机器上确有别的负载(`tasklist` 可见多个 go/link 进程 = 塔里其它 worker 在
// 编译;同窗后端也在跑群聊回合);同一夜 review-m15 的独立窗口(24 次单请求:n=4
// max 0.236s、n=8 max 1.097s)**一次尖峰都没有** ⇒ 尖峰强依赖窗口负载。因此这里只
// 断言证据支持得住的那一层:**至少存在一个与批次无关的外部/争用分量**(4 条/片也能
// 到 7.139s ⇒ 尖峰**不是**本次放大批次的后果);**越界的频率是否随批次上升,1/40 与
// 2/40 的样本不足以判定,归因未定**(方向反而更像「批次越大越容易越界」,但那需要带
// 在飞计数的观测才能定论)。
//
// 另一件确定的事:因为「一个分片失败 ⇒ 整条 Embed 失败」,一次尖峰就丢掉其余分片
// 已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留问题,**调常量解决不了**
//(修法是重试/分片级容错,不在本切片内)。
//
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
//
// python - <<'PY'
// import json,sqlite3,time,urllib.request
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute(
// "select content from memory_items order by id limit 64")]
// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]):
// t=[r[0] for r in sqlite3.connect("file:data/cyrene.db?mode=ro",uri=True).execute(
// "select content from memory_items where status='active' and persona_id='cyrene'"
// " and scope='user' order by created_at desc, rowid desc limit 201")]
// for b in (t[:1],t[:4],t[:8],t[:16],t[:64]):
// body=json.dumps({"model":MODEL,"input":b}).encode()
// r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"})
// s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read()
@@ -54,43 +111,78 @@ const (
// perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。
perCharBudget = 5 * time.Millisecond
// perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字,
// 最坏摊销 0.456 s/条)向上取整到 500ms。
perItemBudget = 500 * time.Millisecond
// perItemBudget 是**每条文本**的墙钟预算上界:它守住「条数」那一维。
//
// 2026-10-13 由 500ms 降到 300ms —— 旧值是把条数上限压到 **4** 的那个约束项,
// 而 4 条/片会把一次召回放大成 ⌈N/4⌉ 个 HTTP 请求(201 条的审查链 = 51 个请求,
// 见文件头)。依据不是「拍一个更小的数」,而是**实测摊薄**:整条链里每条的平均
// 成本 70~103ms(8 条/片轮次 0.824s ÷ 8 ≈ 103ms;64 条档最坏 6.872s ÷ 64 ≈
// 107ms),300ms 仍是实测的约 **3 倍**。按不变式反解,它把条数上限放到 8:
// (5s/2 − 100ms) / 300ms = 8 —— 恰好用满「超时的一半」(另一半按建模拟合留给现场
// 抖动;它是**建模口径**、不是延迟保证,见文件头的不变式一节)。
//
// 为什么不再大:条数上限 12 需要 perItemBudget ≤ 200ms(只剩实测的 1.9 倍),
// 16 需要 ≤ 150ms(1.4 倍)—— 而 16 条/片本次已复现出**单请求 11.043s**
// (> 单次超时 5s,见文件头)。批次越靠近那条界,单请求越可能「客户端先放弃、
// 服务端还在算」(级联堆积的起点)。取 8:**请求数减半,且建模上界对实测摊销仍留
// 约 3 倍**(这是建模余量,不是单请求一定不越界的保证 —— 见文件头 ④)。
perItemBudget = 300 * time.Millisecond
// callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量
// (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
// callBudgetShare 表示一次请求最多用掉超时的 1/2。它是**建模口径**(一半给建模拟合、
// 一半留给现场抖动),**不是**延迟保证 —— 文件头 ④ 的尖峰整段越过单次超时。调大它
// = 建模上界更紧,必须连带复核实测。
callBudgetShare = 2
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
// (5s/2 − 100ms) / 500ms = 4。
// (5s/2 − 100ms) / 300ms = 8。
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」:
// 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」。
//
// 本次**不动**它(每字符成本实测 2.7~3.9ms,5ms 仍是上界),因此两道界的分工
// 是:8 条 × 均长 35 字(280 字)走**条数界**;8 条 × 长正文(如 168 字 = 1344 字)
// 被它再切一刀,落到 2 条/片。两维都必须成立,缺哪一维都会有请求越界。
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
// maxSafeBatchLimit 是条数上限的**实测外沿**(12),它守的是与上面那条不等式
// **不同**的一层:不等式管的是**建模成本**(requestOverhead + 批次 × perItemBudget
// ≤ 超时的一半),它管的是**实测形状** —— 文件头里 16 条/片复现出 11.043s 的单
// 请求(> 5s 超时),8 条/片三轮最大 1.782s,隔离测量 12 条 0.996~1.017s 仍在界内。
// 两层必须同时成立:把批次放到 12 需要**同时**把 perItemBudget 压到 ≤ 200ms
// (只剩实测摊销的 1.9 倍,且算式上 100ms + 12×200ms = 2.5s 已顶到边界);
// 越过 12 则已进入「实测已知会出界」的区间 —— 由下面那条编译期断言拦住。
maxSafeBatchLimit = 12
// warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个**
// 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个**
// 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。
//
// 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查单条预算、
// 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的:
// - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s;
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s
// (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查,
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算
// (memory.defaultReviewTimeout = 90s)⇒ 一次冷启动不可能吃光整轮审查,
// 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住
// (api_url_test.go 的 TestDefaultRequestTimeout)。
//
// 依据:2026-10-13 部署后第一分钟的生产日志里有一条
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
// —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线),
// 所以放宽只需要覆盖「进程的第一个请求」这一刻。
// —— 模型还没热时 5s 不够;而本次实测稳态单条只要 0.069~0.076s(见文件头的
// 形状曲线),所以放宽只需要覆盖「进程的第一个请求」这一刻。
warmupTimeout = 8 * time.Second
)
// 编译期不变式(2026-10-13):条数上限一旦越过**实测划出来的外沿**
// (maxSafeBatchLimit)—— 例如把 perItemBudget 压到 150ms(⇒ 16)、或把
// callBudgetShare / requestOverhead 往「放宽」的方向调 —— 下面这行常量表达式
// 会变成负数、uint 溢出 ⇒ **编译失败**。与 tidy_config.go 里那条「整理侧批上限
// 不许超过 embedding 推出来的值」同一手法:把实测承诺交给编译器守,而不是只写在
// 注释里。反方向的漂移(perItemBudget 调大 ⇒ 批上限变小 ⇒ 报错变多)由
// budget_test.go 的钉值断言抓。
const _ = uint(maxSafeBatchLimit - DefaultBatchLimit)
// callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。
func callBudget(timeout time.Duration) time.Duration {
if timeout <= 0 {
+45 -6
View File
@@ -19,16 +19,21 @@ import (
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
//
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
// 以及**变异自证**:把批上限调回旧值(4 或 64)会让哪条测试红,见
// TestBatchBudget_InvariantHolds(钉值 + 两层上界)与
// TestAPIEmbedder_LegacyBatchOf64TimesOut。
//
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 3.4ms,此处取上界)。
const (
measuredOverhead = 50 * time.Millisecond
measuredPerChar = 4 * time.Millisecond
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
latencyModelChars = 33 // 活库短正文的平均长度(实测 33~35 汉字)
// measuredPathologicalItems 是**实测**已经出界的批次形状(文件头:单请求
// 11.043s > 5s 超时)。它只用于说明「上界不该往哪边放」,不是断言对象 ——
// 断言对象是推出来的 DefaultBatchLimit 与它必须落在的 maxSafeBatchLimit 之内。
measuredPathologicalItems = 16
)
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
@@ -115,7 +120,18 @@ func shortTexts(n int) []string {
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
//
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
// 四层,各自的语义不同(改坏任一层都必须是红的):
//
// 1. **钉值**:推出来的条数上限必须是 8、字符上限必须是 480。它是「常量变了必须
// 连带复核链长/病态区」的那根 tripwire —— 例如把 perItemBudget 调回 500ms
// (⇒ 4)或 200ms(⇒ 12)都会让这一条红,而不是静默改掉整条链的请求数。
// 2. **不变式(条数维)**:requestOverhead + 批次 × perItemBudget ≤ 超时的一半。
// 3. **不变式(字符维)**:requestOverhead + 字符上限 × perCharBudget ≤ 超时的一半。
// 4. **实测外沿**:条数上限必须 ≤ maxSafeBatchLimit(12),且**实测已知出界的形状**
// (16 条/片,单请求 11.043s)在推演下必须仍然判为越界 —— 即推演不许松到
// 把 16 条当成一个批次。
//
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第 1 层红;
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
func TestBatchBudget_InvariantHolds(t *testing.T) {
t.Parallel()
@@ -123,19 +139,42 @@ func TestBatchBudget_InvariantHolds(t *testing.T) {
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
}
// 1. 钉值:值变了就必须连带复核「审查链请求数」与「病态区上界」两件事。
if DefaultBatchLimit != 8 {
t.Fatalf("DefaultBatchLimit = %d, want 8(值变了要连带复核审查链请求数 ⌈201/批次⌉ 与 maxSafeBatchLimit)",
DefaultBatchLimit)
}
if DefaultBatchChars != 480 {
t.Fatalf("DefaultBatchChars = %d, want 480(字符维本次刻意未动;改了要复核长正文分片)",
DefaultBatchChars)
}
share := DefaultRequestTimeout / callBudgetShare
// 2. 条数维:批次上限 × 每条预算 + 固定开销 ≤ 超时的一半。
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
if byItems > share {
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchLimit, byItems, share)
}
// 3. 字符维:同一个不等式换成每字符预算。
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
if byChars > share {
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchChars, byChars, share)
}
// 4. 实测外沿:条数上限不得越过实测划出来的那条线(12),而且**实测已出界的
// 形状**(16 条/片)在推演下必须仍是越界的 —— 否则说明推演松到把已知的病态
// 形状也当成合法批次了。
if DefaultBatchLimit > maxSafeBatchLimit {
t.Fatalf("条数上限 %d 越过实测外沿 %d(16 条/片实测单请求 11.043s > 单次超时 %s)",
DefaultBatchLimit, maxSafeBatchLimit, DefaultRequestTimeout)
}
if pathological := requestOverhead + time.Duration(measuredPathologicalItems)*perItemBudget; pathological <= share {
t.Fatalf("%d 条/片的耗时上界 %s 竟落在超时的一半 %s 之内 —— "+
"推演已经松到把这个实测出界的形状当成合法批次了",
measuredPathologicalItems, pathological, share)
}
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
// 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。
// 实测 64 条 6.872s > 5s。这里用同一套预算常量复核它仍然出界。
legacy := requestOverhead + 64*perItemBudget
if legacy <= DefaultRequestTimeout {
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
+320
View File
@@ -0,0 +1,320 @@
package memory
import (
"context"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件是**临时期限到期处置的第 ② 步与第 ③ 步**:后台到期清扫(物理 DELETE)
// 与「删前留审计」(design/23 §19,主人 Q1 裁定「临时记忆到期删除」)。
//
// 三步是刻意分开的(§19.1):
//
// ① 隐藏 —— 读侧时效闸(timegate.go):每次读比一次列值,**零写入**、即时;
// ② 删除 —— 本文件:显式、有界、有审计、可停的后台路径;
// ③ 审计 —— 本文件:**每行删除前**先写一条 `memory.tidy.expire`。
//
// 把删除混进读路径是这条链上最容易犯的错:读路径会因此变成「有副作用且不可逆」,
// 一次带 bug 的查询就能删记忆。因此删除只有这里一个出口,只由后台件调用。
//
// 🔴 **与「写路径唯一」红线怎么共存**:`design/23 §15.1 D3-1` 那条约束的是
// **INSERT(入库)**,`Store.Add` 是唯一 INSERT 路径。到期删除是一条 **DELETE
// (清除)**,不是第二条入库路径;既有先例是 `Store.Delete` / `DeleteScoped` 与
// `preinsert_gc.go` 的硬删留痕,两者都只删行、不造行。本清扫与之同类:**自带审计**,
// 不碰 `Store.Add`,也不复用任何「想写记忆」的调用点。
//
// 🔴 **删除范围写死**(`design/23 §19.2`,逐条可测):
//
// expires_at <> '' ∧ expires_at <= now ∧ status = 'active' ∧ 来源非受限
//
// - **空 `expires_at` = 永不过期,绝不删**。长期 / 日常记忆的 `expires_at` 是
// 空串(`store_schema.go` 的 DDL 默认值),因此它们**结构性**进不了扫描集合 ——
// 这不是靠注释约定,而是靠 SQL 里那条 `expires_at <> ''` 谓词。
// - **受限来源(群聊 / 陌生人)不参与**:判据复用整理/审查同一处实现
// `nonOwnerSource`(`tidy_rules.go`),因此三处口径不可能各说各话。
// - **已软删 / 已归档的行不参与**(`status='active'` 谓词):它们不是「到期的
// 临时记忆」,是整理软动作留下的行;该不该清是另一件事。
const (
// DefaultExpireSweepInterval 是到期清扫的默认周期:每小时一次
// (`design/23 §19.1` 表格「到期后 ≤ 一个清扫周期(默认每小时扫一次)」)。
// 与 `DefaultIngestReviewGCInterval` 同值同由:清扫只是删几行,频率再高无收益。
DefaultExpireSweepInterval = time.Hour
// expireSweepBatchSize 是单批取行 / 删除的行数上限,maxExpireSweepBatches 是
// 一轮清扫的批数上限。两者一起把「一轮清扫」的时长、单次内存占用与**连接池
// 占用**(本项目的 SQLite 池是单连接)都变得有界 —— 照 `preinsert_gc.go` 里
// `ingestReviewSweepBatchSize` / `maxIngestReviewSweepBatches` 那一对常量的
// 批删范式(`design/23 §19.3-3`「一批删除有条数 / 批数上限」)。
// 上限之积 = 一轮最多删 1 万行;积压时剩下的留给下一轮,不一次 DELETE 全表。
expireSweepBatchSize = 500
maxExpireSweepBatches = 20
// expireAuditSummaryRunes 是审计摘要的**按 rune 固定上限**(`design/23 §19.4`)。
// 48 个 rune 足够一眼看出「删的是哪条」,同时把审计表增长压住 —— 到期审计是
// **逐条高基数**行(一行一个 id),不截断等于把正文搬进审计表。取 48 而不是
// 更宽的值:摘要只需可辨认,不需要可复原(正文早已不可逆地删掉了)。
expireAuditSummaryRunes = 48
// expireAuditVerb 是到期删除的审计动作名,与 `memory.tidy.run/.apply/.reject`
// 同族(`design/23 §19.4`)。**只此一处**允许审计携带内容片段,且
// 只在删除这一动作上(本包其余审计一律只带计数)。
expireAuditVerb = "memory.tidy.expire"
)
// ExpirySweepPolicy 是一轮清扫的口径。零值即默认(由 normalized 填批次上限),
// 因此调用方可以只写 `ExpirySweepPolicy{}`。
//
// 它**没有「关掉清扫」的开关** —— 这是刻意的:临时记忆的到期删除是「声明了生命
// 周期就兑现定义」,留一个能关掉它的开关等于让库里的临时层无界留存(读侧闸只会
// 让它们「看不见」,不会让它们消失)。真要不产生临时记忆,该关的是第五态
// `defer` 的判据,而不是清扫。
type ExpirySweepPolicy struct {
// BatchSize 是单批上限;<=0 取 expireSweepBatchSize。
BatchSize int
// MaxBatches 是一轮的批数上限;<=0 取 maxExpireSweepBatches。
MaxBatches int
}
// normalized 填默认值。负值与非正值一律取默认(配置异常不该让清扫停摆)。
func (p ExpirySweepPolicy) normalized() ExpirySweepPolicy {
if p.BatchSize <= 0 {
p.BatchSize = expireSweepBatchSize
}
if p.MaxBatches <= 0 {
p.MaxBatches = maxExpireSweepBatches
}
return p
}
// ExpirySweepStats 是一轮清扫的汇总(**只记计数,绝不记任何行内容**)。
// 它进调用方的一条汇总日志,不进审计 —— 审计是逐行的那一条(ExpireFunc)。
type ExpirySweepStats struct {
// Deleted 是本轮物理删掉的行数。
Deleted int64
// Restricted 是本轮**跳过**的受限来源行数(群聊 / 陌生人)。它非零只说明库里
// 存在这类历史遗留行,是运维信号,不是错误。
Restricted int
// Batches 是本轮实际执行的取行批数。
Batches int
}
// ExpiredMemory 是一条**即将被物理删除**的到期临时记忆,只带审计需要的字段。
// 它刻意不带 tags / confidence 之类的列:审计口径就是 `design/23 §19.4` 那一行
// `id + persona_id + scope + expires_at + 截断摘要`,多带一列就多一分泄漏面。
type ExpiredMemory struct {
ID string
PersonaID string
Scope string
ExpiresAt string
Content string
}
// ExpireFunc 在**每行被删除之前**调用一次,是本清扫的审计出口
// (`design/23 §19.3-3`:每行删除前先写审计)。它没有返回值:审计出口失败与否
// 都不该改变「这一行删不删」—— 审计是留痕,不是闸。为 nil 时不写审计
// (只有不关心留痕的单元测试会这么用;生产的后台件恒传一个)。
type ExpireFunc func(row ExpiredMemory)
// expireCursor 是清扫的**键集游标**(`(expires_at, rowid)`),让被跳过的受限行
// 只被读到一次:每批处理完就前移到该批最后一行,下一批从它之后取。
//
// 为什么不用 `OFFSET`:受限行会被跳过但不删,用 OFFSET 分页时它们会**反复占住
// 每一批的头部**,极端情况下(头部 500 行都是受限行)真该删的行永远轮不到。
// 键集游标让进度与「删了多少」解耦 —— 只要取到了行,扫描位置就一定前移。
type expireCursor struct {
expiresAt string
rowid int64
}
// expiredMemoryRow 是扫描回来的一行:审计字段 + 受限判定要用的溯源 + 游标。
type expiredMemoryRow struct {
ExpiredMemory
sourceConversation string
cursor expireCursor
}
// selectExpiredMemoriesSQL 按 `design/23 §19.2` 的扫描集合取一批到期行。
//
// 三条谓词各自对应一条边界:`expires_at <> ”` 排除「永不过期」的行(空串是
// 长期 / 日常记忆的默认值,见 store_schema.go 的 DDL),`expires_at <= ?` 取到期
// 的行,`status = ?` 只取在册行。时间戳用定宽 UTC 文本比较:字典序等价于时间序,
// 与写入侧 `formatTime`(timefmt.go)同源 —— 换成 SQLite 的 datetime('now') 会
// 静默判错(timegate.go 有这条反证的实证)。
//
// 游标谓词用 `(expires_at, rowid)` **元组比较**:expires_at 是文本列,同一批
// 落库的行可能同值,只比 expires_at 会让扫描卡在原地。
const selectExpiredMemoriesSQL = `SELECT id, persona_id, scope, expires_at, content,
source_conversation, rowid
FROM memory_items
WHERE status = ? AND expires_at <> '' AND expires_at <= ?
AND (expires_at > ? OR (expires_at = ? AND rowid > ?))
ORDER BY expires_at, rowid
LIMIT ?`
// deleteExpiredMemorySQL 按主键删一行。它**只**由上一条语句选出来的 id 驱动,
// 因此不存在「按用户输入删行」的面。
const deleteExpiredMemorySQL = `DELETE FROM memory_items WHERE id = ?`
// SweepExpiredMemories 跑一轮到期清扫,返回汇总。now 由调用方给出(便于测试注入
// 固定时钟;本包不在 Store 里藏墙钟,与 SweepIngestReviews 同口径)。
//
// 边界(`design/23 §19.3-3`):
//
// - **有界**:一轮最多 maxExpireSweepBatches × expireSweepBatchSize 行,
// 每批一次取行 + 逐行删除,连接池占用与时长都有上界;
// - **有审计**:每行删除前调 onExpire(§19.4 的 `memory.tidy.expire`);
// - **可停**:ctx 取消即刻返回;循环体在每批开跑前复核 ctx.Err()。
//
// 失败语义:取行 / 删行失败时**返回错误并带上已完成的统计**(调用方记 WARN 后等
// 下一轮)。已删的行保持删除、未删的行绝不误删 —— 一轮失败只是「这一轮没扫干净」。
func (s *Store) SweepExpiredMemories(
ctx context.Context, now time.Time, policy ExpirySweepPolicy, onExpire ExpireFunc,
) (ExpirySweepStats, error) {
var stats ExpirySweepStats
if s == nil {
return stats, ErrNilStore
}
p := policy.normalized()
// 与写入侧同源:列里存的是 formatTime 产出的定宽 UTC 文本,右边也必须由
// 同一个函数产出(见 timegate.go 的「同源」口径)。
cutoff := formatTime(now.UTC())
cursor := expireCursor{rowid: -1}
for batch := 0; batch < p.MaxBatches; batch++ {
if err := ctx.Err(); err != nil {
return stats, fmt.Errorf("memory: sweep expired memories: %w", err)
}
rows, err := s.listExpiredMemories(ctx, cutoff, cursor, p.BatchSize)
if err != nil {
return stats, err
}
if len(rows) == 0 {
break
}
stats.Batches++
// 游标前移与「删没删」无关:受限行被跳过时也必须往前走,否则下一批会
// 取回同一批行(见 expireCursor 的注释)。
cursor = rows[len(rows)-1].cursor
for _, row := range rows {
if nonOwnerSource(row.sourceConversation) {
stats.Restricted++
continue
}
if onExpire != nil {
onExpire(row.ExpiredMemory)
}
if err := s.purgeExpiredMemory(ctx, row.ID); err != nil {
return stats, err
}
stats.Deleted++
}
if len(rows) < p.BatchSize {
break
}
}
return stats, nil
}
// listExpiredMemories 取一批到期行(含游标与溯源列)。limit 非正时返回空切片,
// 因此调用方不必自己防零。
//
// 它把整批**读进内存后才返回**(游标在返回前关闭),所以调用方的删除不会与一个
// 还开着的 SELECT 游标打架 —— 本项目的 SQLite 池是单连接,同一连接上「边读边删」
// 是自寻死路(写要等读,读永远读不完)。
func (s *Store) listExpiredMemories(
ctx context.Context, cutoff string, cursor expireCursor, limit int,
) ([]expiredMemoryRow, error) {
if limit <= 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, selectExpiredMemoriesSQL,
string(tidyStatusActive), cutoff, cursor.expiresAt, cursor.expiresAt, cursor.rowid, limit)
if err != nil {
return nil, fmt.Errorf("memory: list expired items: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]expiredMemoryRow, 0, limit)
for rows.Next() {
var r expiredMemoryRow
if err := rows.Scan(&r.ID, &r.PersonaID, &r.Scope, &r.ExpiresAt, &r.Content,
&r.sourceConversation, &r.cursor.rowid); err != nil {
return nil, fmt.Errorf("memory: scan expired item: %w", err)
}
r.cursor.expiresAt = r.ExpiresAt
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate expired items: %w", err)
}
return out, nil
}
// purgeExpiredMemory 删掉一行到期临时记忆,并把它的向量从持久索引里同步清掉。
//
// 两步的顺序不是随手写的:**先删行、再清向量**。反过来(先清向量)的话,删行
// 失败会留下一行「有行、无向量」的记忆 —— 那一行不会被返回(读侧闸挡着),
// 但检索的语义路径会有一个查不到向量的 id;而先删行时最坏情况只是留一个孤儿
// 向量,那是本 finding 描述的既有状态(可下一轮再清),不会更坏。
func (s *Store) purgeExpiredMemory(ctx context.Context, id string) error {
if _, err := s.db.ExecContext(ctx, deleteExpiredMemorySQL, id); err != nil {
return fmt.Errorf("memory: delete expired item %s: %w", id, err)
}
s.purgeExpiredVector(ctx, id)
return nil
}
// purgeExpiredVector 把刚被删除的行从持久语义索引里同步清掉(切片 M1 的 reviewer
// 交上来的 finding):过期行的向量若留在索引里,会占掉 `idx.Search` 的 top-K
// 预算,只降召回质量、不泄漏(该行已被读侧时效闸挡住,不可能被返回)。
//
// 未接索引、或后端不支持删除(只读的桩)时是空操作;失败只记 WARN ——
// **向量清不掉不该让「行已删」这件事失败**,也不该让下一行不删。
func (s *Store) purgeExpiredVector(ctx context.Context, id string) {
idx := s.vectorIndex()
if idx == nil {
return
}
deleter, ok := idx.(vector.Deleter)
if !ok {
s.log.Debug("memory: vector index does not support delete, expired vector left behind", "id", id)
return
}
if err := deleter.Delete(ctx, id); err != nil {
s.log.Warn("memory: expired vector purge failed", "id", id, "error", err)
}
}
// expireAuditResult 渲染 `design/23 §19.4` 要求的定长结构化串:
//
// id=<id> persona_id=<..> scope=<..> expires_at=<..> summary=<截断摘要>
//
// 摘要是**本仓唯一**允许进入审计的内容片段,边界限这一个动作 + 固定截断
// (主会话已裁定允许)。它先做空白归一(正文里的换行 / 制表符会把审计行拆成
// 多行,那既破坏「一行一条」的可解析性,也把事情变成「把正文刷进日志」),
// 再按 rune 截断。
func expireAuditResult(row ExpiredMemory) string {
return fmt.Sprintf("id=%s persona_id=%s scope=%s expires_at=%s summary=%s",
row.ID, row.PersonaID, row.Scope, row.ExpiresAt, clipExpireSummary(row.Content))
}
// clipExpireSummary 把正文压成一行至多 expireAuditSummaryRunes 个 rune 的摘要。
// 空白(含换行)一律归一成单个空格;截断时追加省略号,让「这条被截过」在审计
// 里可见 —— 省略号本身也占一个 rune,总长仍恒为 ≤ 上限(上限-1 + 省略号)。
//
// 空正文(理论上不该存在,正文是 NOT NULL 且入库时校验非空)返回空串,
// 让审计行仍可解析。
func clipExpireSummary(content string) string {
flat := strings.Join(strings.Fields(content), " ")
runes := []rune(flat)
if len(runes) <= expireAuditSummaryRunes {
return flat
}
return string(runes[:expireAuditSummaryRunes-1]) + "…"
}
+89
View File
@@ -0,0 +1,89 @@
package memory
import (
"context"
"time"
)
// 本文件是到期清扫的**后台件形态**:一个独立循环(与 `preinsert_gc.go` 的
// `sweepLoop` 同形),启动期先扫一次、此后每 DefaultExpireSweepInterval 扫一次。
//
// 设计口径(`design/23 §19.3-2`):删除只由内存模块的后台件串行触发,与整理 /
// 审查同一单写者纪律,**不新开并发写口**、不新开监听端口、不加任何 `.env` 键
// (`.env` 已冻结,`design/13 §2.1`),也不碰会话 / 历史读数。
//
// 🔴 **挂点**:清扫与整理**同生共死** —— 它的 ctx 是 TidyService.Start 里建的那个
// cctx(就是 `go s.loop(cctx)` 用的同一个),它的 goroutine 登记进同一个 `s.wg`。
// 因此 `cmd/cyrene/wire_services.go` 关闭链里的 `a.memoryTidy.Stop(ctx)` 会一并
// 取消它并等它退出(§19.3-3 的「可停」),不必再为清扫加一条关闭路径。
//
// 🔴 挂点**必须**是 `Start` 里那处显式的 `go s.expirySweepLoop(cctx)`,不许改挂到
// 某个注入缝上(例如包一层 `s.wait` 这个「心跳等待原语」)。理由是**可证性**:
// 包注入缝意味着清扫的存活依赖于「`loop` 每一轮都调 `s.wait`」这个**没有任何契约
// 固定**的假设 —— 将来谁换了等待原语、或把循环改成 `select` + 自己的 timer,清扫
// 会**静默消失**(不报错、不红测试,只是临时期限再也删不掉)。起在 `Start` 里,
// 读者一眼就能看到两个后台件同生共死。
// expirySweepLoop 是清扫的常驻循环:启动期先扫一次(清掉上次退出后到期的行),
// 此后按 DefaultExpireSweepInterval 扫一次。它随 ctx 取消退出(Stop 会 join 它)。
//
// 它**不**读任何记忆正文之外的输入:清扫只按 expires_at / status / 溯源列删行。
func (s *TidyService) expirySweepLoop(ctx context.Context) {
defer s.wg.Done()
s.expirySweepOnce(ctx)
ticker := time.NewTicker(DefaultExpireSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.expirySweepOnce(ctx)
}
}
}
// expirySweepOnce 跑一轮到期清扫。失败只记 WARN:清扫是旁路维护,绝不让整理服务
// 退出、也绝不影响任何一条**未到期**的记忆(与 `preinsert_gc.go` 的 fail-safe
// 同口径:已删的保持删除、未删的留给下一轮)。
//
// 日志口径(`design/23 §19.4`):**一轮只写一行汇总**,逐条的那一行进审计出口
// (见 auditExpiredMemory)。这里绝不逐条把正文刷进日志。
func (s *TidyService) expirySweepOnce(ctx context.Context) {
store := s.tidier.deps.Store
if store == nil {
return
}
stats, err := store.SweepExpiredMemories(ctx, s.now(), ExpirySweepPolicy{}, s.auditExpiredMemory)
if err != nil {
// ctx 取消(关闭中)不是故障:安静退出,不制造一条误导性的 WARN。
if ctx.Err() != nil {
return
}
s.log.Warn("memory expiry sweep failed, expired rows left for the next round",
"error", err, "deleted", stats.Deleted, "restricted_skipped", stats.Restricted)
return
}
if stats.Deleted == 0 && stats.Restricted == 0 {
s.log.Debug("memory expiry sweep: nothing expired")
return
}
s.log.Info("memory expiry sweep",
"deleted", stats.Deleted, "restricted_skipped", stats.Restricted, "batches", stats.Batches,
"interval", DefaultExpireSweepInterval.String())
}
// auditExpiredMemory 是每行删除前的审计出口(`design/23 §19.4`):
// `who = system/memorytidy`、`what = memory.tidy.expire`,result 见 expireAuditResult。
//
// 没有审计出口时**只记 id,不记摘要**:摘要仍是内容片段,把它逐条刷进日志正是
// 本设计要避免的那件事(「一轮汇总只进日志」)。
func (s *TidyService) auditExpiredMemory(row ExpiredMemory) {
hook := s.tidier.deps.Audit
if hook == nil {
s.log.Debug("memory expiry audit dropped: no sink configured",
"what", expireAuditVerb, "id", row.ID)
return
}
hook(tidyAuditWho, expireAuditVerb, expireAuditResult(row))
}
+277
View File
@@ -0,0 +1,277 @@
package memory
import (
"bytes"
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件锁定期清扫的**接线与后台形态**(design/23 §19):挂点(TidyService.Start
// 里的 `go s.expirySweepLoop(cctx)`)、随 Stop 可停、一轮只写一行汇总日志,以及
// 「删行时同步清掉向量」这条 finding 的集成断言。清扫自身的边界(哪些行删、
// 哪些行留、审计长什么样)在 expire_test.go。
//
// TestExpirySweepOnce_一轮只写一行汇总:逐条的那一行进审计,日志里一轮只有一行
// (§19.4「一轮汇总只进日志,绝不逐条把正文刷进日志」)。
func TestExpirySweepOnce_一轮只写一行汇总(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
var buf bytes.Buffer
var mu sync.Mutex
audit := func(who, what, result string) {
mu.Lock()
defer mu.Unlock()
_ = who
_ = what
_ = result
}
svc, err := NewTidyService(tidyTestCfg(0.80), TidyDeps{
Store: store, Log: slog.New(slog.NewTextHandler(&buf, nil)),
Now: fixedTidyClock(now), Audit: audit,
})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
for i := 0; i < 3; i++ {
addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "日志里不该出现的正文片段", ExpiresAt: now.Add(-time.Minute),
})
}
svc.expirySweepOnce(ctx)
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 1 {
t.Fatalf("一轮写入了 %d 行日志,want 1(只允许一行汇总):\n%s", len(lines), buf.String())
}
if !strings.Contains(lines[0], "deleted=3") {
t.Fatalf("汇总行没有计数:%q", lines[0])
}
if strings.Contains(buf.String(), "日志里不该出现的正文片段") {
t.Fatalf("日志里出现了正文 —— 逐条正文只许进审计的截断摘要:%q", buf.String())
}
}
// TestTidyServiceStart_清扫随服务起停 钉住**接线**(design/23 §19.3-3 的「可停」):
// 清扫起在 TidyService.Start 里,与心跳共用同一个 cctx 与 wg,因此
//
// ① Start 一跑它就立刻扫一轮(启动补扫,清掉上次退出后到期的行);
// ② Stop 会取消它并等它退出 —— Stop 没超时即证明它真的随 ctx 走了。
//
// 它同时是 `cmd/cyrene/wire_memory_tidy_test.go` 那条 seam 断言的运行时对照:把
// Start 里那行 `go s.expirySweepLoop(cctx)` 摘掉,这条用例会红(行永远不会被删,
// 且 Stop 会因为 wg 计数对不上而超时)。
func TestTidyServiceStart_清扫随服务起停(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
// 10:00Z:不在安静窗(23–7)内 ⇒ Start 里的补跑判定为「不该跑」,整理不搅局。
now := tidyTestTime(t)
row := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "随服务启动被扫掉的到期行", ExpiresAt: now.Add(-time.Minute),
})
var (
mu sync.Mutex
audits []string
)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(now),
func(_, what, result string) {
mu.Lock()
defer mu.Unlock()
audits = append(audits, what+" "+result)
})
// 假等待:把整理心跳停在 ctx 上(真定时器要等 24h 档)。它只影响心跳 ——
// 清扫是**另一个** goroutine + 自己的小时级 ticker。
svc.wait = func(ctx context.Context, _ time.Duration) bool {
<-ctx.Done()
return false
}
svc.Start(ctx)
waitUntilRowGone(t, store, row.ID)
stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := svc.Stop(stopCtx); err != nil {
t.Fatalf("Stop 未能在预算内合上 —— 清扫没有随 ctx 退出 / 没登记进同一个 wg: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(audits) != 1 || !strings.HasPrefix(audits[0], expireAuditVerb+" ") {
t.Fatalf("审计 = %v, want 一条 what=%s", audits, expireAuditVerb)
}
}
// TestTidyServiceStart_整理关掉时清扫不跑 钉住那条**行为契约**(不是断言字段那种
// 假牙):`MEMORY_TIDY_ENABLED=false` ⇒ 到期清扫**不跑**,过期行**一条都不删**。
//
// 为什么值得一条专门的用例:清扫那行 `go s.expirySweepLoop(cctx)` 就排在
// `if !s.cfg.Enabled { … return }` **之后**。将来谁把 Enabled 检查挪到后面、
// 或让 Start 在关掉时也走完整条路,过期行就会在「整理被关掉」时被物理删除 ——
// 那是与「后台件随宿主启停」(`preinsert_gc` 随审查服务启停,design/23 §19.3-2)
// 相反的行为,而且**不会红任何别的用例**(读侧闸照样挡着,表面上一切正常)。
//
// 断言是「没有删除发生 + 没有审计 + running 保持 false」三件一起 —— 只看
// `cfg.Enabled` 字段是假牙:那证明不了 Start 真的按关掉处理。
func TestTidyServiceStart_整理关掉时清扫不跑(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
row := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "整理关掉时不该被删的到期行", ExpiresAt: now.Add(-time.Minute),
})
var (
mu sync.Mutex
audits []string
)
cfg := tidyTestCfg(0.80)
cfg.Enabled = false
svc := newTidyServiceForTest(t, store, cfg, nil, fixedTidyClock(now),
func(_, what, result string) {
mu.Lock()
defer mu.Unlock()
audits = append(audits, what+" "+result)
})
// 假等待:万一清扫/心跳被起起来了,它会停在这里而不是空转(不影响断言)。
svc.wait = func(ctx context.Context, _ time.Duration) bool {
<-ctx.Done()
return false
}
svc.Start(ctx)
// ① 确定性断言:Start 走的是「关掉 ⇒ 直接返回」那条路。
if svc.running {
t.Fatal("Enabled=false 时 Start 应当直接返回,running 必须保持 false")
}
// ② 让「清扫其实被起起来了」有机会显形:清扫是启动即扫一轮,中间只有
// 「取行 + 删行」一次 SQLite 往返(同一台机上实测在微秒级,远比这个窗口小);
// 对照组 TestTidyServiceStart_清扫随服务起停 用的就是同一个语料与同一个时钟,
// 它在毫秒级内把行删掉。给同一个数量级的窗口再断言「什么也没发生」。
time.Sleep(200 * time.Millisecond)
if !memoryRowExists(t, store, row.ID) {
t.Fatal("整理被关掉了,到期行却被删了 —— 清扫跑在了 Enabled 闸之外")
}
mu.Lock()
defer mu.Unlock()
if len(audits) != 0 {
t.Fatalf("整理被关掉了,却写出了到期审计:%v", audits)
}
}
// TestSweepExpiredMemories_同步清掉向量 是切片 M1 的 reviewer 交上来的那条 finding
// 的断言:过期行被物理删除时,它的**向量也必须一起清掉** —— 否则向量留在索引里
// 占掉 idx.Search 的 top-K 预算,召回质量静默下降(行本身已被读侧闸挡住,不会泄漏)。
func TestSweepExpiredMemories_同步清掉向量(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
idx := newTestVectorIndex(t, 64)
store.attachVectorIndex(idx)
now := tidyTestTime(t)
expired := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "到期的向量条目", ExpiresAt: now.Add(-time.Minute),
})
kept := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "长期留着的向量条目",
})
if !vectorIndexHas(t, ctx, idx, expired.ID) {
t.Fatal("前置条件不成立:到期行的向量本来就该在索引里(写入侧没喂进去)")
}
if !vectorIndexHas(t, ctx, idx, kept.ID) {
t.Fatal("前置条件不成立:保留行的向量本来就该在索引里")
}
if _, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, nil); err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if vectorIndexHas(t, ctx, idx, expired.ID) {
t.Fatal("到期行删了、向量还在索引里 —— 它会占掉 top-K 预算(M1 reviewer 的 finding)")
}
if !vectorIndexHas(t, ctx, idx, kept.ID) {
t.Fatal("保留行的向量被误清了(清算成了通杀)")
}
}
// vectorIndexHas 用 n-gram 索引自带的 embedder 查一次,报告某 id 是否还在索引里。
func vectorIndexHas(t *testing.T, ctx context.Context, idx vector.Index, id string) bool {
t.Helper()
vecs, err := idx.Embed(ctx, []string{"到期的向量条目 长期留着的向量条目"})
if err != nil {
t.Fatalf("embed query: %v", err)
}
hits, err := idx.Search(ctx, vecs[0], 50)
if err != nil {
t.Fatalf("search index: %v", err)
}
for _, h := range hits {
if h.ID == id {
return true
}
}
return false
}
// TestClipExpireSummary 单钉摘要裁剪这件小事:空白归一(含换行)、按 rune 截断、
// 截断才加省略号。
func TestClipExpireSummary(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{name: "短文本原样", in: "谢谢啦", want: "谢谢啦"},
{name: "空白归一", in: "一\n\t二 三", want: "一 二 三"},
{name: "空文本", in: " ", want: ""},
{
name: "超长按 rune 截断并省略号",
in: strings.Repeat("很", expireAuditSummaryRunes+10),
want: strings.Repeat("很", expireAuditSummaryRunes-1) + "…",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := clipExpireSummary(tc.in); got != tc.want {
t.Fatalf("clipExpireSummary(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// waitUntilRowGone 轮询等一行被删掉(清扫跑在另一个 goroutine 上)。
func waitUntilRowGone(t *testing.T, store *Store, id string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if !memoryRowExists(t, store, id) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等不到行 %s 被清扫删除(清扫没跑起来?)", id)
}
+311
View File
@@ -0,0 +1,311 @@
package memory
import (
"context"
"errors"
"strings"
"testing"
"time"
"unicode/utf8"
)
// 本文件锁定**临时期限到期处置的第 ② / ③ 步**(design/23 §19,切片 M3):
// 到期行的物理删除、删前审计、以及四条边界。第 ① 步(读侧隐藏)由
// timegate_test.go 覆盖,本文件只断言「真删掉」这一半。
//
// 四条边界逐条有断言,因为它们各自对应一次「删错就再也回不来」的事故:
//
// ① 空 `expires_at`(长期 / 日常记忆,含 M2 那条 INSERT-与-补写之间的中断窗口)
// **绝不删** —— 判据是 SQL 里的 `expires_at <> ''`;
// ② 未到期 **绝不删**;
// ③ 受限来源(群聊 / 陌生人)**不参与**,且不为它写审计;
// ④ 审计带的是**按 rune 截断的摘要**,绝不是正文全文(本仓唯一一处允许携带
// 内容片段的审计,边界限 `memory.tidy.expire` 一个动作)。
// memoryRowExists 报告一条记忆行是否还在。Store.Get 刻意不过任何读侧闸
// (store.go),因此它是「行在不在」的直接探针:过期行与已软删行都读得到。
func memoryRowExists(t *testing.T, store *Store, id string) bool {
t.Helper()
if _, err := store.Get(context.Background(), id); errors.Is(err, ErrNotFound) {
return false
} else if err != nil {
t.Fatalf("Get(%s): %v", id, err)
}
return true
}
// expiryFixture 建三条典型行:一条已到期、一条未到期、一条**不设过期**
// (`expires_at=”`,即长期 / 日常记忆的形态)。
type expiryFixture struct {
expired MemoryItem
future MemoryItem
longTerm MemoryItem
}
func seedExpiryFixture(t *testing.T, store *Store, now time.Time) expiryFixture {
t.Helper()
base := func() MemoryItem {
return MemoryItem{PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship}
}
expired := base()
expired.Content = "临时记忆:明天要去买咖啡豆"
expired.ExpiresAt = now.Add(-time.Hour)
future := base()
future.Content = "临时记忆:还没到期的那条"
future.ExpiresAt = now.Add(time.Hour)
longTerm := base()
longTerm.Content = "长期记忆:主人喜欢冰美式"
return expiryFixture{
expired: addItem(t, store, expired),
future: addItem(t, store, future),
longTerm: addItem(t, store, longTerm),
}
}
// TestSweepExpiredMemories_过期删_未到期与长期保留 是设计 §19.2 扫描集合的正例与
// 两条反例:只有「非空且已过去的 expires_at」的行被删,未到期行与不设过期的行
// 一律保留。
func TestSweepExpiredMemories_过期删_未到期与长期保留(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
fx := seedExpiryFixture(t, store, now)
var audited []ExpiredMemory
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, func(row ExpiredMemory) {
audited = append(audited, row)
})
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("deleted = %d, want 1(只有那一条到期的该删)", stats.Deleted)
}
if memoryRowExists(t, store, fx.expired.ID) {
t.Fatal("到期行没有被物理删除 —— 读侧闸只负责「看不见」,删除是这一步的事")
}
if !memoryRowExists(t, store, fx.future.ID) {
t.Fatal("未到期行被删了(扫描集合写错)")
}
if !memoryRowExists(t, store, fx.longTerm.ID) {
t.Fatal("空 expires_at(= 不设过期)的行被删了 —— 长期 / 日常记忆必须结构性进不了删除集合")
}
if len(audited) != 1 || audited[0].ID != fx.expired.ID {
t.Fatalf("审计行 = %+v, want 只这一条到期行 %s", audited, fx.expired.ID)
}
}
// TestSweepExpiredMemories_空expires_at绝不删 是**单独**钉住那条判据的用例:
// 它是 M2 登记的「INSERT 与补写 expires_at 之间若进程中断,该行会以 expires_at=”
// 留存」那条窗口的护栏 —— 方向是「宁可多留」,因此那种行**永远**不该被清扫删掉。
//
// 断言刻意写成「哪怕它是 active 且 created_at 很旧」也一样保留:清扫不看创建时间。
func TestSweepExpiredMemories_空expires_at绝不删(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
orphan := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "进程在补写 expires_at 之前中断留下的行",
// 刻意落在很早以前,证明清扫不看 created_at。
CreatedAt: now.Add(-1000 * time.Hour),
})
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, nil)
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Deleted != 0 {
t.Fatalf("deleted = %d, want 0(空 expires_at = 永不过期)", stats.Deleted)
}
if !memoryRowExists(t, store, orphan.ID) {
t.Fatal("空 expires_at 的行被删了 —— 那是「永不过期」,不是「过期很久」")
}
}
// TestSweepExpiredMemories_受限来源保留 是 §19.2 的第三条边界:群聊 / 陌生人来
// 源的临时记忆**不参与**到期删除(判据复用 nonOwnerSource,与整理 / 审查同一处
// 实现),且**不为它写审计** —— 删都没删,凭什么留一条「删了」的痕。
func TestSweepExpiredMemories_受限来源保留(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
restricted := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "群聊里冒出来的一条(历史遗留)",
SourceConversation: FormatSourceConversation("sess-telegram:group1", "m1"),
ExpiresAt: now.Add(-time.Hour),
})
owner := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "主人私聊里那条到期的",
SourceConversation: FormatSourceConversation("web", "m2"),
ExpiresAt: now.Add(-time.Hour),
})
var audited []string
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, func(row ExpiredMemory) {
audited = append(audited, row.ID)
})
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Restricted != 1 {
t.Fatalf("restricted = %d, want 1(受限来源要被识别并跳过)", stats.Restricted)
}
if stats.Deleted != 1 {
t.Fatalf("deleted = %d, want 1(只删主人链路那条)", stats.Deleted)
}
if !memoryRowExists(t, store, restricted.ID) {
t.Fatal("受限来源(群聊)的到期行被删了 —— §19.2 要求它不参与到期删除")
}
if memoryRowExists(t, store, owner.ID) {
t.Fatal("主人链路的到期行没有被删(受限判据把正常行也误伤了)")
}
if len(audited) != 1 || audited[0] != owner.ID {
t.Fatalf("审计 = %v, want 只有 %s(受限行不该留「删了」的痕)", audited, owner.ID)
}
}
// TestSweepExpiredMemories_只删在册行:已软删 / 已归档的过期行不参与 —— 它们不是
// 「到期的临时记忆」,是整理软动作留下的行,该不该清是另一件事(§19.2 第一条)。
func TestSweepExpiredMemories_只删在册行(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
merged := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "被软合并掉的过期行", ExpiresAt: now.Add(-time.Hour),
})
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET status = ? WHERE id = ?`, ingestStatusMerged, merged.ID); err != nil {
t.Fatalf("update status: %v", err)
}
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, nil)
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Deleted != 0 {
t.Fatalf("deleted = %d, want 0(非在册行不参与)", stats.Deleted)
}
if !memoryRowExists(t, store, merged.ID) {
t.Fatal("已软删(status='merged')的行被到期清扫删了")
}
}
// TestSweepExpiredMemories_有界批次:一轮清扫的批数 × 单批上限是有界的,积压时
// 剩下的留给下一轮 —— 绝不「一次 DELETE 全表」(§19.3-3 的有界)。
func TestSweepExpiredMemories_有界批次(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
const rows = 5
ids := make([]string, 0, rows)
for i := 0; i < rows; i++ {
ids = append(ids, addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "过期行", ExpiresAt: now.Add(-time.Duration(i+1) * time.Minute),
}).ID)
}
policy := ExpirySweepPolicy{BatchSize: 2, MaxBatches: 2}
first, err := store.SweepExpiredMemories(ctx, now, policy, nil)
if err != nil {
t.Fatalf("first sweep: %v", err)
}
if first.Deleted != 2*2 || first.Batches != 2 {
t.Fatalf("一轮 = deleted %d / batches %d, want 4 / 2(有界:2 批 × 每批 2 行)",
first.Deleted, first.Batches)
}
second, err := store.SweepExpiredMemories(ctx, now, policy, nil)
if err != nil {
t.Fatalf("second sweep: %v", err)
}
if second.Deleted != 1 {
t.Fatalf("第二轮 deleted = %d, want 1(剩下的留给下一轮)", second.Deleted)
}
for _, id := range ids {
if memoryRowExists(t, store, id) {
t.Fatalf("两轮之后仍有到期行 %s 未删(有界不该变成永远删不完)", id)
}
}
}
// TestSweepExpiredMemories_审计在删之前且带截断摘要 是 §19.4 的核心断言:
//
// 1. **删前留痕**:审计回调被调用时那一行**还在**(顺序反了就事后无从追责);
// 2. **摘要按 rune 截断**:不超过 expireAuditSummaryRunes,且截断处留省略号;
// 3. **绝不含正文全文**:审计里找不到那条长正文;
// 4. 正文里的换行被归一成空格(审计行恒为一行,可解析)。
func TestSweepExpiredMemories_审计在删之前且带截断摘要(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
longText := strings.Repeat("很长的临时记忆正文", 30) // 300 rune
row := addItem(t, store, MemoryItem{
PersonaID: "persona.b", Scope: ScopeProject, Kind: KindWorldEvent,
Content: "第一行\n\t第二行 " + longText,
ExpiresAt: now.Add(-time.Minute),
})
var (
results []string
existedAtAudit []bool
)
_, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, func(r ExpiredMemory) {
existedAtAudit = append(existedAtAudit, memoryRowExists(t, store, r.ID))
results = append(results, expireAuditResult(r))
})
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if len(results) != 1 {
t.Fatalf("审计行数 = %d, want 1", len(results))
}
if !existedAtAudit[0] {
t.Fatal("审计发生时那一行已经被删了 —— §19.3-3 要求「每行删除**前**先写审计」")
}
got := results[0]
if !strings.HasPrefix(got, "id="+row.ID+" persona_id=persona.b scope=project expires_at=") {
t.Fatalf("审计串缺字段:%q", got)
}
if strings.Contains(got, "\n") || strings.Contains(got, "\t") {
t.Fatalf("审计带换行 / 制表符,会破坏「一行一条」:%q", got)
}
if strings.Contains(got, longText) {
t.Fatalf("审计里出现了正文全文 —— 摘要必须被截断:%q", got)
}
_, summary, ok := strings.Cut(got, "summary=")
if !ok {
t.Fatalf("审计串没有 summary 段:%q", got)
}
if n := utf8.RuneCountInString(summary); n > expireAuditSummaryRunes {
t.Fatalf("摘要 %d rune,超过上限 %d:%q", n, expireAuditSummaryRunes, summary)
}
if !strings.HasSuffix(summary, "…") {
t.Fatalf("截断处应当留省略号(让人一眼看出被截过):%q", summary)
}
// 摘要必须是**归一化之后**正文的前缀(证明它是「截断」而不是别的东西)。
flat := strings.Join(strings.Fields(row.Content), " ")
if !strings.HasPrefix(flat, strings.TrimSuffix(summary, "…")) {
t.Fatalf("摘要不是(空白归一后的)正文前缀:%q", summary)
}
}
+37 -2
View File
@@ -174,7 +174,20 @@ type PreInsertReviewer struct {
// 推进时钟(见 preinsert_t1_limit.go)。
t1Gate *t1RateGate
mu sync.Mutex
// mu 保护 running / cancel 两个生命周期字段,以及**运行期可变的那两个配置
// 字段**(`DisableIngestGate` / `TempTTL`,面板调优会改它们,见
// preinsert_tuning.go 的 ApplyPanelTuning)。
//
// 它用 RWMutex 而不是 Mutex,是因为后者在**写入路径**上被高频读:同步入库闸
// 每写一条记忆就要问一次 `gateEnabled()`,第五态 `defer` 还要问一次
// `deferDeadline()`;读锁之间不互斥,因此这两处不会因为面板保存而互相排队。
//
// 🔴 口径(切片 MEMPANEL / M4 立):**谁在运行期写 cfg,谁就必须让读它的地方
// 也持这把锁**。今天运行期只写上面那两个字段,因此只有读这两个字段的两处
// (`gateEnabled` / `deferDeadline`)走 `tuningSnapshot`;其余读点读的是构造期
// 冻结的字段,不与写方交手,故**没有**一并加锁(不给热路径加无谓的锁)。
// 将来面板再接管别的字段,那几处读点必须与它一起收。
mu sync.RWMutex
running bool
cancel context.CancelFunc
wg sync.WaitGroup
@@ -208,10 +221,32 @@ func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer
}, nil
}
// tuningSnapshot 在读锁下取一份配置快照。
//
// 为什么需要它(切片 MEMPANEL / M4):面板调优(preinsert_tuning.go 的
// `ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段;在这条特性之前 `cfg` 是
// 构造期冻结的,读侧无锁本来安全。凡是读这两个字段的地方因此都必须与写方同步,
// 否则就是「写方持锁、读方不持锁」的数据竞争(`go test -race` 可复现)。
//
// 取**整份**快照而不是逐个字段读,是为了让一次判定看到的是**同一个版本**的配置
// (两个字段分别读可能落到两次不同的写入之间)。
//
// 它只做一次字段拷贝、**不调用任何别的函数**,因此不会引入新的锁序:写方
// (ApplyPanelTuning)持写锁时也只做字段赋值,不会再去等别的锁。
func (r *PreInsertReviewer) tuningSnapshot() ReviewConfig {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cfg
}
// gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。
// 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。
func (r *PreInsertReviewer) gateEnabled() bool {
return r != nil && r.cfg.Enabled && !r.cfg.DisableIngestGate
if r == nil {
return false
}
cfg := r.tuningSnapshot()
return cfg.Enabled && !cfg.DisableIngestGate
}
// gateScanLimit 返回同步闸实际使用的候选上限:配置值与其默认值的较小者 ——
+46 -2
View File
@@ -18,8 +18,49 @@ const (
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限。
defaultReviewTimeout = 20 * time.Second
// defaultReviewTimeout 是单条审查的墙钟上限,**也是全链的唯一真源**
// (切片 M16,2026-10-13):配置层 `config.MemoryReviewConfig.TimeoutSec`
// 的 env tag **刻意不带** `default:`,没配时读出 0(= 没配),
// 由本文件下方的 normalizeReviewConfig 落地成这个常量。
//
// 🔴 为什么要把真源搬到这里:原先 tag 上写着 `default:"20"`,而 20s 又等于
// 当时的本常量 ⇒ 看着「两处一致」,实际是 **env 层永远产出一个非零值**,
// 「≤0 才回落默认」这条归一化**永远是死代码** ⇒ 本常量改成 90s 后生产启动行
// 仍是 `timeout=20s`(塔在部署后读真实启动行抓到)。所以别再往 env tag 上写
// 字面默认值:那等于把「默认值」搬回配置层,本常量又一次变成装饰。
//
// 取值推导如下(2026-10-13 由 20s 抬到 90s),依据是**实测的链长**,不是感觉:
// 单条审查的召回链是「新条 1 + ScanLimit 200 = 201 条文本」,而 embedding 侧
// 一次请求只吃 DefaultBatchLimit 条(改前 4、改后 8,见 embedding/budget.go 的
// 文件头实测)⇒ 链长 = ⌈201/批次⌉ 个请求:
//
// 改前(4 条/片)51 个请求:整条链 **23.070 / 21.860 s**(空闲机器,两轮实测)
// 改后(8 条/片)26 个请求:整条链 **21.435 / 21.597 s**(同上两轮)
// 另有一次**并发整理链**时测到的同一条链 = **56.7 s**(M14 只读勘测报告)
//
// ⇒ 旧值 20s **比链长还短**:即使机器空闲,一条候选数满额(200)的审查也跑不完
// 它的 embedding,于是父 ctx 到期 ⇒ 整条审查失败、**一行留痕都不写**(7 天内
// 92.1% 的审查 `recall_mode` 为空,其中超时降级 2274 条,正是这条链的后果)。
//
// 取 90s 的推导:预算必须 > 链长 + 本层后面的 T1 灰区判定(defaultT1Timeout 8s)。
// 空闲实测 21.6s ⇒ 4.2 倍余量;并发实测 56.7s + 8s = 64.7s ⇒ 仍有 1.4 倍余量。
//
// 为什么**不再更大**(例如 180s / 300s):
// - 审查是**异步、单 worker、有界队列**(QueueSize 256,preinsert.go 的
// `go r.worker`)。worker 的最坏占用 = 一条审查的预算 ⇒ 队列 256 条的最坏
// 积压 = 256 × 90s ≈ **6.4 小时**(180s 就是 12.8 小时)。预算再大,等于把
// 「worker 被一条慢审查占住」的时间线性放大。
// - 90s 已经是 embedding 单次超时(5s)的 **18 倍**、T1 自身超时(8s)的 11 倍。
// embedding 服务**真挂死**时这条预算根本用不到 —— 第一片请求在 5s 就失败,
// 召回立刻降级到词项余弦、审查照常跑完(这一点是结构性事实,见
// preinsert_recall.go 的降级路径)⇒ 预算只对「慢但在推进」的链有意义。
// 那种形态说明 embedding 服务本身退化了,该修的是服务,不是继续等。
// - 审查超时后的失败形态目前**不写留痕**(preinsert.go 的 `ctx.Err()` 分支,
// 不属本次改动范围)⇒ 预算越长,「静默停摆」的窗口越长、面板越看不出来。
//
// 🔴 与设计稿的对齐(如实登记):design/23 §15 那一行原写 `Timeout=20s`(本次改动
// **之前**的口径),已按上面实测同步为 `Timeout=90s`。
defaultReviewTimeout = 90 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
reviewAuditWho = "system/memoryreview"
@@ -130,6 +171,9 @@ func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
cfg.ScanLimit = defaultReviewScanLimit
}
if cfg.Timeout <= 0 {
// 单条审查预算的**唯一**落地处(切片 M16):配置层的 env tag 不带默认值,
// 所以「没配」与「配了 ≤0」都走这一行。全链的生效值就是这一行的结果 ——
// 启动行 `memory ingest review started … timeout=` 打印的也正是它。
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
+1 -1
View File
@@ -237,7 +237,7 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
// 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此
// 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。
func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time {
return now.UTC().Add(r.cfg.TempTTL)
return now.UTC().Add(r.tuningSnapshot().TempTTL)
}
// failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
@@ -82,6 +82,18 @@ func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch:
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败,
// 由调用方决定降级还是上报,绝不半途改用混合表示。
//
// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头):
// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数
// = ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
//
// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s
// 批次 8(改后)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
//
// 两点必须一起读:① 请求数减半是本改动**唯一**买到的收益 —— 链路墙钟由
// 「条数 × 每条成本」主导,两个批次形状都是 ~21s;② 所以单条审查的预算
// (memory.defaultReviewTimeout)必须按**整条链**算,20s 的旧值比链长还短,
// 会让每一条候选满额的审查都超时(这正是「92% 的审查 recall_mode 为空」的根因)。
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(items))
for start := 0; start < len(items); start += tidyEmbedBatch {
@@ -1,19 +1,79 @@
package memory
import (
"bytes"
"context"
"log/slog"
"os"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// TestEmbeddingTimeoutFitsReviewBudget 钉住一条**跨包**口径:embedding 单次调用的
// 独立超时必须严格小于入库前审查的单条总预算(defaultReviewTimeout = 20s)。
// measuredReviewChain* 是**实测参照值**(2026-10-13,本机空闲;正文取自活库
// cyrene/user 分区 201 条,均长 34.7 汉字;完整曲线与复跑命令见
// internal/runtime/embedding/budget.go 的文件头)。它们只用于本文件的断言 ——
// 与 embedding 侧 api_url_test.go 的 reviewBudgetRef 同一手法:把「拿实测数字做的
// 决定」留成可复核的常量,而不是消失在注释里。
//
// 这不是风格问题:2026-10-07 实测的故障形态就是「端点主机写成 localhost → 先试
// IPv6 ::1 → 连接等系统超时(Windows 上约 21s)」,一次 embedding 失败正好吃光 20s
// 预算,整条审查超时(`memory/review: context deadline exceeded`),排在后面的 T1
// 链的形状 = ⌈(ScanLimit + 1) / embedding.DefaultBatchLimit⌉ 个请求:
//
// 改前 4 条/片:51 个请求 ⇒ 墙钟 23.070 / 21.860 s
// 改后 8 条/片:26 个请求 ⇒ 墙钟 21.435 / 21.597 s
//
// loaded 那一档取自 M14 只读勘测报告(同一条链与整理链并发时测得 56.7s)——
// 它是按**改前**的 4 条/片形状测的;链路墙钟由「条数 × 每条成本」主导、与批次
// 形状几乎无关,因此拿它当改后代码的最坏参照是**偏保守**的一侧。
const (
measuredReviewChainIdle = 22 * time.Second
measuredReviewChainLoaded = 57 * time.Second
)
// reviewTimeoutEnvUnset 让 MEMORY_REVIEW_TIMEOUT_SEC 在本次用例中**真的未设置**。
//
// 为什么不用 t.Setenv(key, ""):空串在 decode.go 的 lookupRaw 里**照样命中 env
// 分支**(`os.LookupEnv` 对空值返回 ok=true)⇒ 配置层读出 0,于是**看不见 env tag
// 上的字面默认值** —— 而那正是本切片的回归面(tag 上再加回 `default:"20"` 必须
// 让下面的生效值用例变红)。只有「未设置」才会走到 tag 默认那一档。
// (用 os.Unsetenv 而不是 t.Setenv 是刻意的:Go 没有「置回未设置」的测试 API,
// 因此这里自己存旧值 + 在 t.Cleanup 里还原。本用例不并行,故无竞争。)
func reviewTimeoutEnvUnset(t *testing.T) {
t.Helper()
old, had := os.LookupEnv("MEMORY_REVIEW_TIMEOUT_SEC")
t.Cleanup(func() {
if had {
_ = os.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", old)
return
}
_ = os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC")
})
if err := os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC"); err != nil {
t.Fatalf("unset MEMORY_REVIEW_TIMEOUT_SEC: %v", err)
}
}
// TestEmbeddingTimeoutFitsReviewBudget 钉住两条**跨包**口径:
//
// 1. embedding 单次调用的独立超时必须严格小于入库前审查的单条总预算;
// 2. 反过来,审查的单条总预算必须严格大于「实测最坏召回链长 + T1 自己的超时」。
//
// 第 1 条的理由(2026-10-07 实测的故障形态):端点主机写成 localhost → 先试 IPv6
// ::1 → 连接等系统超时(Windows 上约 21s),一次 embedding 失败正好吃光预算,
// 整条审查超时(`memory/review: context deadline exceeded`),排在后面的 T1
// 灰区判定连执行机会都没有。地址规范化(embedding.normalizeEndpointURL)治的是根,
// 本断言守的是「就算再有新的卡死来源,也得先把预算留给后续步骤」。
// 第 1 条守的是「就算再有新的卡死来源,也得先把预算留给后续步骤」。
//
// 第 2 条是本次修复(2026-10-13)的核心断言,**变异自证**:把 defaultReviewTimeout
// 调回 20s ⇒ 这一条必红。它守的是「预算 ≥ 链长 + T1」这条不可让步的下界 ——
// 链长随 ScanLimit / 批上限漂移,T1 超时随 defaultT1Timeout 漂移,任一侧动到把
// 预算吃掉都会被它抓到。旧值 20s 正是踩在这条线上的:实测链长 21~23s(空闲),
// 于是每一条候选满额的审查都必然超时,7 天里 2274 条审查因此降级为空 recall_mode。
//
// ⚠️ 本条比的是**本包的常量**,因此**看不见跨包漂移**(env 层另有一个默认值时它
// 照样绿)—— 那条防线在下面的 TestReviewTimeoutEffectiveValueIsTheOnlyDefault。
//
// embedding 侧同一口径也有一条钉值测试(api_url_test.go),两边互为护栏。
func TestEmbeddingTimeoutFitsReviewBudget(t *testing.T) {
@@ -26,4 +86,141 @@ func TestEmbeddingTimeoutFitsReviewBudget(t *testing.T) {
t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s",
embedding.DefaultRequestTimeout, defaultReviewTimeout)
}
// 第 2 条:预算必须盖得住实测最坏链长,还要留得下 T1 那一步自己的超时
// (T1 在召回**之后**跑,同一条 ctx)。空闲余量倍数由 budget.go 的注释列明,
// 这里只钉住「盖得住」这条下界。
worst := measuredReviewChainLoaded + defaultT1Timeout
if defaultReviewTimeout <= worst {
t.Fatalf("审查单条预算 %s 必须严格大于「实测最坏召回链 %s + T1 超时 %s = %s」—— "+
"否则链还没跑完预算就用光(2026-10-13 的 2274 条超时降级正是这条)",
defaultReviewTimeout, measuredReviewChainLoaded, defaultT1Timeout, worst)
}
if measuredReviewChainIdle >= defaultReviewTimeout {
t.Fatalf("空闲实测链长 %s 已不小于审查预算 %s(余量必须为正)",
measuredReviewChainIdle, defaultReviewTimeout)
}
}
// TestReviewTimeoutExplicitEnvValueWins 钉住另一半口径(M16):`.env` / 进程环境
// **显式写了**这个键时,它必须**原样到达生效值**(不被 memory 侧的默认值顶掉)。
//
// 与 TestReviewTimeoutEffectiveValueIsTheOnlyDefault 合起来覆盖两个情形:
//
// 没写 ⇒ 生效值 = 90s(唯一真源)
// 写了 45 ⇒ 生效值 = 45s(显式优先,且**不做任何「保底抬到 90」的偷偷加码**)
//
// 后者同样要钉:运维为了让某台机器更早放弃而调低预算,是**显式选择**,
// 代码不许自作主张改回去(要改回去也该改配置,而不是让配置失效)。
func TestReviewTimeoutExplicitEnvValueWins(t *testing.T) {
t.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", "45")
cfg, err := config.LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig: %v", err)
}
if cfg.TimeoutSec != 45 {
t.Fatalf("env 层的 MEMORY_REVIEW_TIMEOUT_SEC = %d, want 45", cfg.TimeoutSec)
}
reviewer, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true,
Threshold: 0.8,
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
}, ReviewDeps{Store: newTestStore(t), Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
if got := reviewer.cfg.Timeout; got != 45*time.Second {
t.Fatalf("显式配置的 45s 没到达生效值:%s", got)
}
}
// TestReviewTimeoutEffectiveValueIsTheOnlyDefault 是**全链生效值**的钉值测试
// (切片 M16,2026-10-13)。它刻意走**完整组装路径**,一层都不绕过:
//
// config.LoadMemoryReviewConfig("") ← env 层("env 档没写这个键" 的形态)
// → ReviewConfig{Timeout: 秒 × time.Second} ← 组合根 cmd/cyrene 那一行
// → NewPreInsertReviewer → normalizeReviewConfig ← 唯一落地处
// → r.cfg.Timeout(= 启动行打印的 timeout=…,塔复验读的就是它)
//
// 🔴 它为什么必须存在(而 TestEmbeddingTimeoutFitsReviewBudget 拦不住这条):
// 那一条比的是**本包的常量** `defaultReviewTimeout`,它从来看不见 env 层。
// 而线上坏掉的形态恰好发生在**另一个包**:config 侧的 env tag 带着
// `default:"20"` ⇒ env 层永远返回 20 ⇒ 本包「≤0 才回落默认」的归一化成了**死代码**
// ⇒ 常量的 90s **一次也没生效过**。两处都是「90 > 实测链长 + T1」的绿色断言,
// 生产启动行却是 `timeout=20s` —— 常量比对与生效值比对是**两件事**:
//
// 比常量 = 「我打算用多少」(单包自洽,跨包漂移看不见)
// 比生效值 = 「进程实际会拿到多少」(跨包漂移立刻见红)
//
// 后者才拦得住「同一个值两处副本、只有一处被改」(本仓 `reviewBudgetRef` 是前科)。
// 变异自证:把 config 侧 tag 的 `default:"20"` 加回去 ⇒ 本用例必红(下方
// cfg.TimeoutSec != 0 那条会先报出来,随后生效值 20s 也会小于下界)。
//
// 不许并行:下面要把 env 档置成**真的未设置**(见 reviewTimeoutEnvUnset 的理由)。
func TestReviewTimeoutEffectiveValueIsTheOnlyDefault(t *testing.T) {
reviewTimeoutEnvUnset(t)
cfg, err := config.LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig: %v", err)
}
if cfg.TimeoutSec != 0 {
// 用 Errorf 而不是 Fatalf:这样**两条防线都会报出来** —— 「配置层又长了
// 一个字面默认值」与「组装后的生效值盖不住实测链长」。变异成修前形态时
// 两条同时见红,读的人不必猜是哪一层坏掉。
t.Errorf("env 层的 MEMORY_REVIEW_TIMEOUT_SEC = %d, want 0(未配置)—— "+
"非零意味着配置层又长出了字面默认值,memory 侧的 %s 会再次成为死代码(M16 的形态)",
cfg.TimeoutSec, defaultReviewTimeout)
}
// 逐字复刻组合根把配置层搬进 ReviewConfig 的那一行
// (cmd/cyrene/wire_memory_review.go:`Timeout: time.Duration(cfg.TimeoutSec) * time.Second`)。
var startupLine bytes.Buffer
reviewer, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true,
Threshold: 0.8,
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
}, ReviewDeps{
Store: newTestStore(t),
Log: slog.New(slog.NewTextHandler(&startupLine, nil)),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
effective := reviewer.cfg.Timeout
// 生效值证据:塔重启后拿它对照生产启动行
// `msg="memory ingest review started" … timeout=…`。
t.Logf("组装后 timeout = %s(env 层未配置;启动行会原样打印它)", effective)
worst := measuredReviewChainLoaded + defaultT1Timeout
if effective != defaultReviewTimeout {
// 同样是 Errorf:与上面那条一起定位「哪一层把默认值改掉了」。
t.Errorf("生效值 %s != 唯一真源 defaultReviewTimeout %s(配置层与 memory 层漂移了)",
effective, defaultReviewTimeout)
}
if effective <= worst {
t.Fatalf("组装后的单条审查预算 %s 必须严格大于「实测最坏召回链 %s + T1 子超时 %s = %s」—— "+
"否则链还没跑完预算就用光(生产 20s 时 7 天里 2274 条审查因此降级为空 recall_mode)",
effective, measuredReviewChainLoaded, defaultT1Timeout, worst)
}
if measuredReviewChainIdle >= effective {
t.Fatalf("空闲实测链长 %s 已不小于生效预算 %s(余量必须为正)",
measuredReviewChainIdle, effective)
}
// 启动行是塔唯一的复验面:它必须真的打印这个生效值。把 Start/Stop 跑起来,
// 因为「日志里写的是 r.cfg.Timeout 还是另一个副本」本身就是要钉住的事。
ctx, cancel := context.WithCancel(context.Background())
reviewer.Start(ctx)
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stopCancel()
if err := reviewer.Stop(stopCtx); err != nil {
t.Fatalf("Stop: %v", err)
}
cancel()
if line := startupLine.String(); !strings.Contains(line, "memory ingest review started") ||
!strings.Contains(line, "timeout="+effective.String()) {
t.Fatalf("启动行没有打印生效值 timeout=%s:%q", effective, line)
}
}
@@ -8,6 +8,8 @@ import (
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是入库前审查(design/23 §15.1 D3)的存储层:候选读取、软合并落定、
@@ -89,6 +91,24 @@ func (s *Store) ingestReviewer() *PreInsertReviewer {
return s.review
}
// ApplyIngestPanelTuning 把面板值**当场**叠到已接入的入库前审查上(切片 MEMPANEL / M4)。
//
// 网关握着的是记忆存储(`deps.Memories`),而不是审查服务本身,因此这条转调就是面板
// 写端点触达审查侧的**唯一**通道(不为此新开依赖面:没有新的 Deps 字段、没有新的
// Server 字段)。
//
// 未接入审查(`MEMORY_REVIEW_ENABLED=false`)时是**空操作 + nil**:那时两个键本来
// 就不生效,运行期没有任何旧值要改,返回错误只会让一次完全正常的保存报「热生效失败」。
// 指针的读写走既有的 `reviewMu`(与 AttachIngestReviewer 同一把锁),不会与并发接入
// 交手。
func (s *Store) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
reviewer := s.ingestReviewer()
if reviewer == nil {
return nil
}
return reviewer.ApplyPanelTuning(panel)
}
// enqueueReview 把刚落库的条目投入审查队列。非阻塞:队列满时审查方自己记
// WARN(它才是唯一知道队列状态的一侧),写入方绝不因此阻塞或失败。
func (s *Store) enqueueReview(item MemoryItem) {
@@ -68,3 +68,39 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
}
return *v
}
// ApplyPanelTuning 把面板值**当场**叠到已经在跑的审查服务上(切片 MEMPANEL / M4)。
//
// 它只碰面板管得到、且**在 `.env` 里结构性没有出处**的两个键(`ingestGate` /
// `tempTtlSec`)—— 因此重算起点就是这两个字段的**零值**(中档为空),不需要一份
// 「`.env` 快照」,也不会因为重算而误伤别的键:`Enabled` / `QueueSize` / `ScanLimit` /
// `Timeout` / `Threshold` / T1 四件套一律**原样不动**(它们来自部署配置,不是面板旋钮;
// 面板端点也拒收它们)。
//
// 口径与装配期逐字一致(`wireMemoryReview` → `NewPreInsertReviewer`):面板写了就用,
// 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
//
// 🔴 并发口径(本方法引入的运行期写入面,已连同读侧一起收口):`cfg` 在这条特性
// 之前是**构造期冻结**的,读侧无锁本来安全;这里开了「运行期改两个字段」这条路,
// 于是读这两个字段的地方也必须同步。写侧持 `r.mu` 的**写锁**;读侧两处
// (`gateEnabled` / `deferDeadline`)走 `r.tuningSnapshot()` 取读锁快照。
// 其余读点读的是构造期冻结的字段(`Enabled` / `QueueSize` / `Timeout` / `Threshold` /
// T1 四件套…),与写方不交手,因此**刻意没有**一并加锁(不给入库热路径加无谓的锁);
// 将来面板再接管别的字段,那些读点必须与它一起收。
//
// 锁序:本方法持锁期间**只做字段赋值**,不去取任何别的锁(`normalizeReviewConfig` /
// `ApplyReviewTuning` 都在取锁**之前**跑完)⇒ 不引入新的锁序,也就不可能与既有路径
// 形成环。
func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log))
r.mu.Lock()
r.cfg.DisableIngestGate = panel.IngestGateOff()
r.cfg.TempTTL = next.TempTTL
gateOn := !r.cfg.DisableIngestGate
ttl := r.cfg.TempTTL
r.mu.Unlock()
r.log.Info("memory review panel tuning applied",
"ingest_gate", gateOn, "temp_ttl", ttl.String(), "source", "admin panel")
return nil
}
+12 -4
View File
@@ -78,10 +78,18 @@ const (
//
// 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演**
// 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半
// ÷ 单条预算上界)。2026-10-13:旧值 64 的实测耗时 7.5~8.5s 已越过客户端 5s
// 单次超时 ⇒ 客户端放弃、服务端继续算 ⇒ 本机 embedding 服务被顶住,单条请求
// (写向量索引)也跟着超时。把批上限与超时绑到同一条推演上,就不会再出现
// 「只调一侧、另一侧静默失效」。
// ÷ 单条预算上界)。2026-10-13 的实测沿革:
// - 旧值 64:单请求实测 6.872s(对比超时 5s)⇒ 客户端放弃、服务端继续算 ⇒
// 本机 embedding 服务被顶住,单条请求(写向量索引)也跟着超时;
// - 先收到 4:单请求落回界内,但把**一次调用**放大成 ⌈N/4⌉ 个请求 ——
// user 分区一轮(CompareLimit 2000 条)就是 500 个请求、审查链 51 个请求;
// - 本次放到 **8**(= embedding.DefaultBatchLimit,`perItemBudget` 500ms→300ms):
// 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大
// 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。
//
// ⚠️ 但它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求
// 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新
// 向量化」这个**放大源仍然存在**(user 分区一轮 250 个请求),本切片不动它。
//
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
+5 -1
View File
@@ -142,7 +142,11 @@ func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
// 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) {
if !s.cfg.Enabled {
// 走 `enabled()` 而不是裸读字段:这个判断在取 runMu **之前**,而面板调优能在运行期
// 覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning)⇒ 该字段的每个读点
// 都得与写方同步,否则 `POST …/tidy/dry-run`(含 `?async=1` 那条后台版)与保存并发
// 时就是数据竞争(`-race` 可复现)。
if !s.enabled() {
return TidyDryRun{}, ErrTidyDisabled
}
if ctx == nil {
+115 -30
View File
@@ -63,8 +63,23 @@ type TidyService struct {
now func() time.Time
// wait 是心跳的等待原语:返回 false 表示 ctx 已取消。生产是 time.Timer;
// 测试注入假等待(推进假时钟后立刻返回),因此补跑判据可以确定性验证。
//
// 🔴 它的返回值语义**没有变**(`false` 仍表示「这一觉没睡成就结束了」):
// 面板调优的重排**不是**靠它自报,而是靠下面那个可被取消的子 ctx —— 于是注入的
// 假等待(不认识 wake 的那些)在测试里语义逐字不变(见 loop 的等待段)。
wait func(ctx context.Context, d time.Duration) bool
// wake 是「面板调优改了配置 ⇒ 把这一觉重排一次」的一次性标记:**有缓冲(cap 1)
// + 非阻塞发**,见 tidy_service_tuning.go 的 notifyReschedule。写端跑在面板的
// HTTP 保存路径上,因此**绝不允许阻塞**;满了丢弃是**正确**语义(通道里那一个
// 标记表达的就是「有一次待处理的重排」,重算是幂等的)。
wake chan struct{}
// wakeMu 保护 wakeCancel(= 取消「当前这一次等待」的手段;两次等待之间是 nil)。
// 它是 wake 的**中断**那一半:只有标记没有中断的话,新周期要等这一觉(可能是一天)
// 睡完才生效 —— 那正是主人把 24h 改成 3h 时看到的「改了跟没改一样」。
wakeMu sync.Mutex
wakeCancel context.CancelFunc
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
// 若撞在一起,后者排队而不是并发改写候选表。
runMu sync.Mutex
@@ -86,6 +101,7 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
log: tidier.log,
now: tidier.now,
wait: waitTidyTimer,
wake: make(chan struct{}, 1),
runs: newTidyRunRegistry(defaultTidyRunTTL),
}, nil
}
@@ -106,13 +122,37 @@ func waitTidyTimer(ctx context.Context, d time.Duration) bool {
func (s *TidyService) SessionID() string { return TidySessionID }
// Enabled 报告整理是否被配置开启(design/23 §4 的 MEMORY_TIDY_ENABLED)。
func (s *TidyService) Enabled() bool { return s.cfg.Enabled }
//
// 它走 `enabled()` 而不是直接读字段:`Enabled` 是 `s.cfg` 里的一个字段,而面板调优
// 能在**运行期**整体覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning),
// 因此该字段的**每一个**读点都必须与写方同步(`s.mu`)。
func (s *TidyService) Enabled() bool { return s.enabled() }
// enabled 在读锁下报告整理开关。它是 `s.cfg.Enabled` 的**唯一**安全读法
// (口径见 scheduleSnapshot 与上面 Enabled 的注释)。
func (s *TidyService) enabled() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg.Enabled
}
// cfgSnapshot 在读锁下取一份配置快照:凡是不需要 lastRun 的读点用它。
func (s *TidyService) cfgSnapshot() TidyConfig {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg
}
// Start 启动心跳。它是幂等的:第二次调用是空操作。配置关闭时只记一条日志
// 并立即返回(关闭是显式选择,绝不静默)。ctx(可为 nil)是所有内部工作的
// 父级:取消它会像 Stop 一样停止服务。
//
// 它**同时**起临时期限的到期清扫(design/23 §19 的第 ② / ③ 步,见
// expire_loop.go):清扫与心跳共用同一个 cctx 与 wg,因此它跟整理同生共死 ——
// 整理被配置关掉时清扫也不跑(与 `preinsert_gc.go` 的清扫随审查服务启停同口径),
// `Stop` 会一并取消并 join 它。
func (s *TidyService) Start(ctx context.Context) {
if !s.cfg.Enabled {
if !s.enabled() {
s.log.Info("memory tidy disabled by configuration", "env", "MEMORY_TIDY_ENABLED")
return
}
@@ -128,23 +168,49 @@ func (s *TidyService) Start(ctx context.Context) {
cctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.wg.Add(1)
// 到期清扫(design/23 §19 的第 ② / ③ 步)与心跳**同生共死**:同一个 cctx、
// 同一个 wg,因此 Stop 会一并取消并 join 它(§19.3-3 的「可停」)。
//
// 🔴 为什么这笔 Add 也在锁内(与上面那一笔同处):让 `Stop` 的 `running` 判定
// 与这两笔 Add **原子化** —— 于是不可能出现「Stop 已看到 `running=false` 早退」
// 或「Wait 漏掉一笔尚未 Add 的 goroutine(那会当场泄漏一个清扫 goroutine)」
// 这两种窗口。
//
// ⚠️ 这把锁**不**覆盖 `Stop` 的 `wg.Wait`:在同一个 `func (s *TidyService) Stop`
// 里,`s.mu.Unlock()` 在前、`s.wg.Wait()`(另一个 goroutine 里)在后,两者之间
// 没有锁。因此 WaitGroup 的复用纪律(「上一轮 Wait 返回前不得再 Add」)**不是**
// 由这把锁给的,而是靠「`Start` / `Stop` 在整个进程生命周期各只调用一次」维持:
// `Start` 只在组合根启动期调一次(`cmd/cyrene/main.go` 的
// `a.memoryTidy.Start(ctx)`)、`Stop` 只在关停链里调一次(`cmd/cyrene/wire_services.go`
// 的 `_ = a.memoryTidy.Stop(ctx)`)。
// 交叉调用(`Start` 与在飞的 `Stop` 并发)在 `-race` 下会 `panic: sync:
// WaitGroup is reused before previous Wait has returned` —— 实测该 panic 在
// M3 之前的基线上同样可复现(既有性质,非本切片引入),只是当前调用点**不可达**。
s.wg.Add(1)
s.mu.Unlock()
// 持久事实先读进来:判定「要不要补跑」只看它,不看进程内状态。
s.loadPersistedLastRun(ctx)
// 两个后台件在这里一起起:整理心跳,以及临时期限的到期清扫
// (expire_loop.go)。清扫跑在**自己的** goroutine 与**自己的**小时级
// ticker 上 —— 整理的周期是 24h 档(intervalSec),不能拿来当清扫周期
// (§19.1「到期后 ≤ 一个清扫周期,默认每小时」)。
go s.loop(cctx)
go s.expirySweepLoop(cctx)
// 启动日志逐项取一次快照(面板调优能在运行期覆写 cfg,因此这些字段也不能裸读)。
cfg := s.cfgSnapshot()
s.log.Info("memory tidy started",
"interval", s.cfg.Interval.String(),
"quiet_interval", s.cfg.QuietInterval.String(),
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour),
"recall_threshold", s.cfg.RecallThreshold,
"scan_limit", s.cfg.ScanLimit,
"compare_limit", s.cfg.CompareLimit,
"candidate_limit", s.cfg.CandidateLimit,
"auto_apply", s.cfg.autoApplyEnabled(),
"auto_apply_per_round", s.cfg.AutoApplyPerRound,
"interval", cfg.Interval.String(),
"quiet_interval", cfg.QuietInterval.String(),
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour),
"recall_threshold", cfg.RecallThreshold,
"scan_limit", cfg.ScanLimit,
"compare_limit", cfg.CompareLimit,
"candidate_limit", cfg.CandidateLimit,
"auto_apply", cfg.autoApplyEnabled(),
"auto_apply_per_round", cfg.AutoApplyPerRound,
"rule_version", TidyRuleVersion,
"session_id", TidySessionID,
"last_run_at", s.lastRunLabel())
@@ -232,7 +298,7 @@ func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) {
// 由 tidy_auto.go 直接落地,其余仍等人点(候选 apply / 按 run 批量 apply)。
// 关掉自动档只需 `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`。
func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
if !s.cfg.Enabled {
if !s.enabled() {
return TidyStats{}, ErrTidyDisabled
}
return s.runRound(ctx, "manual")
@@ -251,11 +317,19 @@ func (s *TidyService) loop(ctx context.Context) {
defer s.wg.Done()
s.catchUp(ctx)
for {
if !s.wait(ctx, s.nextDelay()) {
return
}
if ctx.Err() != nil {
// 上一轮攒下的「调优唤醒」标记在这里清掉:它全部的含义就是「把下一拍重算
// 一遍」,因此必须在重算**之前**清空 —— 否则重算完发现标记还在,会自旋。
s.drainWake()
switch s.waitForTick(ctx) {
case tidyTickStopped:
// 父 ctx 被取消(Stop / 进程退出),或注入的假等待自报「停」:既有语义。
return
case tidyTickWokenByTuning:
// 被**面板调优**唤醒:这一觉是按**旧**周期算出来的,现在作废 ⇒ 只重算
// 下一拍,**绝不**因此跑一轮(唤醒不是「到点了」,判据仍只有 dueNow)。
s.log.Debug("memory tidy heartbeat rescheduled by panel tuning",
"now_hour", s.now().Hour())
continue
}
// 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
// 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
@@ -301,21 +375,19 @@ func (s *TidyService) catchUp(ctx context.Context) {
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
func (s *TidyService) dueNow() (bool, string) {
now := s.now()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
cfg, last := s.scheduleSnapshot()
if last.IsZero() {
if s.cfg.inQuietHours(now) {
if cfg.inQuietHours(now) {
return true, "no_history_in_quiet_window"
}
return false, ""
}
if now.Sub(last) >= s.cfg.intervalAt(now) {
if now.Sub(last) >= cfg.intervalAt(now) {
return true, "interval_elapsed"
}
if s.cfg.inQuietHours(now) {
if start := s.cfg.currentQuietStart(now); last.Before(start) {
if cfg.inQuietHours(now) {
if start := cfg.currentQuietStart(now); last.Before(start) {
return true, "quiet_window_not_run"
}
}
@@ -339,18 +411,18 @@ func (s *TidyService) dueNow() (bool, string) {
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
func (s *TidyService) nextDelay() time.Duration {
now := s.now()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
cfg, last := s.scheduleSnapshot()
// lastRun 与 cfg 必须在**同一把锁**下取到同一版本(见 scheduleSnapshot),
// 否则一次面板保存可能让这里拿到「新周期 + 旧历史」的混合体。
var next time.Time
if last.IsZero() {
next = s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.intervalAt(now))
next = cfg.nextQuietStart(now)
if cfg.quietWindowDisabled() {
next = now.Add(cfg.intervalAt(now))
}
} else {
next = last.Add(s.cfg.intervalAt(now))
next = last.Add(cfg.intervalAt(now))
}
delay := next.Sub(now)
if delay < minTidyTickDelay {
@@ -359,6 +431,19 @@ func (s *TidyService) nextDelay() time.Duration {
return delay
}
// scheduleSnapshot 在锁下同时取「调度配置」与「上次运行时刻」。
//
// 🔴 为什么要有它(切片 MEMPANEL / M4 立):`cfg` 在这条特性之前是启动期冻结的,
// 因此调度读(`nextDelay` / `dueNow`)直接读字段本来安全;面板调优开了「运行期改
// cfg」这条路之后,调度读**必须**与写方(tidy_service_tuning.go 的 ApplyPanelTuning)
// 同步,否则就是数据竞争(`-race` 可复现)。两样东西一起取,是为了让一次判定看到
// 的是**同一个版本**(分开取可能落在两次不同的写入之间)。
func (s *TidyService) scheduleSnapshot() (TidyConfig, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg, s.lastRun
}
// runRound 跑一轮并把结果与审计留痕。单写者:runMu 串行化心跳与手动触发。
func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, error) {
s.runMu.Lock()
@@ -0,0 +1,211 @@
package memory
import (
"context"
"fmt"
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是**整理自动档的运行期调优面**(切片 MEMPANEL / M4):把
// `$DATA_DIR/config/memory-tidy.json` 的面板旋钮**当场**叠到已经在跑的 TidyService 上,
// 使主人点一次「保存」就生效,而不必重启后端。
//
// 为什么需要它(主人 2026-10-07 两条口径,逐字):
//
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
// 面板配置文件。这些键优先从面板配置上取」
// 「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到
// 了静默期稍微长一点」
//
// 在这之前,面板文件只在**装配期**被读一次(`wireMemoryTidy` → `NewTidyService`),
// 四个调度旋钮与 autoApply 被**拷进**服务的 `cfg` 字段后就不再变 —— 面板改完文件,
// 跑着的进程一个字节都不会变。
//
// 三条不可走样的口径:
//
// 1. **重算而不是叠加**:`base` 是 `.env` 层(由组合根用 `TidyConfigFromConfig`
// 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` >
// 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到
// `.env` 档,而不是留在上一次的面板值上。
// 2. **心跳要重排,但不许碰生命周期**:心跳正睡在「用**旧**周期算出来」的那一觉里
// (`nextDelay` 只在每一拍之前现算),最长可到旧周期 —— 不重排的话,主人把周期从
// 24 小时改成 3 小时时,新周期要等那一觉睡完才生效,等于「改了跟没改一样」。
// 重排的做法是**唤醒**(`notifyReschedule`:一次性标记 + 取消当前那次等待),
// **不是** `Stop` + `Start`。这条是本切片修正后的形态,理由见下面「为什么不是
// Stop/Start」。
// 3. **失败不吞值**:重排不成立时值**照样**落到运行期(下一次调度判定就用它),
// 调用方据实回报 —— 绝不假装热生效,也绝不让保存白写。
//
// 为什么不是 `Stop` + `Start`(早先那版就是它,被塔的 review 打回):`Stop` 会连同
// `Start` 里起的**另一只后台件**(到期清扫 `expirySweepLoop`)一起取消,而清扫是
// **启动即扫一轮**的形态 ⇒ 每次面板保存都会顺带触发一次真实删行 + 逐行审计,还会把
// 清扫的 ticker 重置(`design/23 §19.1` 的「到期后 ≤ 一个清扫周期」因此被推后)。
// 更要命的是「停止中重启」这件事本身:`Stop` 的超时分支里 `wg.Wait` 仍在飞,紧接着
// `Start` 的 `wg.Add` 会踩上 `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律
// (交叉调用 Start/Stop 在 `-race` 下 `panic: sync: WaitGroup is reused before previous
// Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。
//
// 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被
// 运行期整体覆写,它的**每一个**读点都必须与写方同步。做法是读写都在 `s.mu` 下:读点
// 收进三个访问器 `enabled()`(开关)/ `cfgSnapshot()`(整份)/ `scheduleSnapshot()`
// (整份 + lastRun),覆盖调度读 `nextDelay` / `dueNow`、两个活端点的开关判定
// `DryRun` / `ManualRun`、`Enabled()` 与 `Start` 的启动日志;此外 `ApplyPanelTuning`
// 里还有一处**同一把锁下**的相等比较(`s.cfg == next`),它本身不需要访问器。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、
// 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
//
// 返回 nil 表示**值已生效,且心跳已被告知重排**(下一拍按新周期算)。当前实现里
// 它**不会失败**(错误返回保留给将来的校验),因此网关侧的 `hot` 语义就是「值已叠进
// 运行期」。
func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
next := tidyPanelTuning(base, panel, s.log)
s.mu.Lock()
unchanged := s.cfg == next
s.mu.Unlock()
if unchanged {
// 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写,也
// 不必唤醒心跳。刻意**不**走下面那条写路径:写一个「相同的值」也是写,
// 照样会与调度读构成竞争,而它一点收益都没有。
return nil
}
// 写运行期(两处持有者),再把「这一觉是按旧周期算的」这件事作废。
s.applyTidyConfig(next)
s.notifyReschedule()
s.log.Info("memory tidy panel tuning applied",
"interval_sec", next.IntervalSec, "quiet_interval_sec", next.QuietIntervalSec,
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", next.QuietStartHour, next.QuietEndHour),
"auto_apply", !next.DisableAutoApply, "source", "admin panel")
return nil
}
// notifyReschedule 通知心跳「周期变了 ⇒ 把这一觉重排一次」。
//
// 两件事,缺一不可:
// - **标记**(非阻塞发一个有缓冲的通道):保证这次通知**不会丢** —— 如果心跳此刻
// 不在等(正在跑一轮、或还没 Start),标记会留在缓冲里,等它下一次「等」之前被
// 清掉并重算。cap 1 + `default` 是刻意的:调用方在**面板保存的 HTTP 路径**上,
// 绝不能阻塞;而通道里那一个标记表达的就是「有一次待处理的重排」,重算是幂等的,
// 因此满了直接丢弃是**正确**语义(丢的只是重复通知,不是改动)。
// - **中断**(取消当前那次等待):保证这次通知**立刻**生效 —— 只有标记的话,新的
// 24h→3h 要等原来那一觉(可能是一天)睡完才落地,那正是「改了跟没改一样」。
//
// 两件事都不碰生命周期(不 Start / 不 Stop / 不动 wg 与 running),因此到期清扫不会
// 被顺带停掉或重启(见文件头「为什么不是 Stop + Start」)。
func (s *TidyService) notifyReschedule() {
select {
case s.wake <- struct{}{}:
default:
}
s.wakeMu.Lock()
cancel := s.wakeCancel
s.wakeMu.Unlock()
if cancel != nil {
cancel()
}
}
// armWake 登记「取消当前这一次等待」的手段;下一次调优保存靠它把这一觉当场作废。
func (s *TidyService) armWake(cancel context.CancelFunc) {
s.wakeMu.Lock()
s.wakeCancel = cancel
s.wakeMu.Unlock()
}
// disarmWake 撤掉登记(每次等待结束都要做,且必须在取消子 ctx **之前**)——
// 否则下一次等待会被上一次的取消误伤。
func (s *TidyService) disarmWake() {
s.wakeMu.Lock()
s.wakeCancel = nil
s.wakeMu.Unlock()
}
// drainWake 清掉待处理的唤醒标记(非阻塞)。心跳在**重算下一拍之前**调它,否则
// 标记会让「被唤醒 ⇒ 重算 ⇒ 又被唤醒」变成自旋。
func (s *TidyService) drainWake() {
select {
case <-s.wake:
default:
}
}
// tidyTickOutcome 是一次「等下一拍」的结局。
type tidyTickOutcome int
const (
// tidyTickWaited:睡满了(或注入的假等待自报可以继续)⇒ 照常做 dueNow 判定。
tidyTickWaited tidyTickOutcome = iota
// tidyTickWokenByTuning:被面板调优的重排打断 ⇒ 只重算下一拍,**不跑轮**。
tidyTickWokenByTuning
// tidyTickStopped:父 ctx 被取消,或注入的假等待自报「停」⇒ 退出循环。
tidyTickStopped
)
// waitForTick 睡到下一拍,返回这次等待的结局(见 tidyTickOutcome)。
//
// 三步的顺序不能变:
// 1. **先登记**取消手段(armWake)—— 否则调优那一下没法作废这一觉;
// 2. 调**注入的** `wait`(生产是 waitTidyTimer;测试可注入假等待),但给它的是一份
// **可取消的子 ctx** ⇒ 唤醒 = 取消它。`wait` 的返回语义因此**一个字都没改**:
// `false` 仍表示「没睡成就结束了」(生产实现里它只在 ctx 结束时返回 false)。
// 3. 无论结果如何都撤掉登记并回收子 ctx。
//
// 判定顺序是刻意的:**先看父 ctx**,再看「子 ctx 是不是被取消」(= 调优唤醒)。反过来
// 的话,Stop 的那一下会被读成「一次唤醒」,心跳就静默没了。
func (s *TidyService) waitForTick(ctx context.Context) tidyTickOutcome {
wctx, wake := context.WithCancel(ctx)
s.armWake(wake)
waited := s.wait(wctx, s.nextDelay())
s.disarmWake()
woken := wctx.Err() != nil
wake()
switch {
case ctx.Err() != nil:
return tidyTickStopped
case woken:
return tidyTickWokenByTuning
case !waited:
return tidyTickStopped
default:
return tidyTickWaited
}
}
// tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。
//
// 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 →
// ApplyTidyScheduleTuning → normalizeTidyConfig),只是可以随时再跑一次。刻意做成
// 一个函数而不是把两处各写一遍逻辑,是为了让「优先级链」只有一份可读的写法:
// 将来把组合根那一段改成调用它,两处就彻底合一(组合根那个文件不在本切片 scope 内,
// 因此这里先留一个等价的实现,并已在回报里登记)。
func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
cfg := base
cfg.DisableAutoApply = panel.AutoApplyOff()
if panel.AutoApplyPerRound > 0 {
cfg.AutoApplyPerRound = panel.AutoApplyPerRound
}
cfg = ApplyTidyScheduleTuning(cfg, panel, log)
return normalizeTidyConfig(cfg)
}
// applyTidyConfig 把新配置落到**两个**持有者上:
// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源(`s.mu` 下写);
// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。
//
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
//
// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它)—— 调优路径**不等
// 任何后台件停**:`s.cfg` 的读点全部持 `s.mu`(见文件头),`tidier.cfg` 的读点全部持
// `runMu`,因此这把锁足以让写入与每一轮的读不交手,不需要(也不再)停心跳。
func (s *TidyService) applyTidyConfig(cfg TidyConfig) {
s.runMu.Lock()
defer s.runMu.Unlock()
s.mu.Lock()
s.cfg = cfg
s.mu.Unlock()
s.tidier.cfg = cfg
}
@@ -0,0 +1,532 @@
package memory
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效,
// 不要求重启后端)的两条判据:
// ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)——
// 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳
// 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。
// ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒
// 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。
// tuningInt 返回面板调优用的 *int。
func tuningInt(v int) *int { return &v }
// tuningBool 返回面板调优用的 *bool。
func tuningBool(v bool) *bool { return &v }
// TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认,
// 且**重算而不是叠加**:清掉一个键就回到 base 那一档。
func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
t.Parallel()
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
// 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: tuningInt(10800),
QuietIntervalSec: tuningInt(36000),
AutoApply: tuningBool(false),
}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour {
t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval)
}
if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour {
t.Fatalf("quiet interval = %d / %s, want 36000 / 10h",
svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval)
}
if !svc.cfg.DisableAutoApply {
t.Fatal("autoApply:false 应当落到 DisableAutoApply")
}
if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 {
t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg)
}
// 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。
if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply {
t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg)
}
// 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400,
// 而不是留在上一次的 10800。
cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)}
if err := svc.ApplyPanelTuning(base, cleared); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if svc.cfg.IntervalSec != 86400 {
t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec)
}
if svc.cfg.QuietIntervalSec != 86400 {
t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec)
}
if svc.cfg.DisableAutoApply {
t.Fatal("清掉 autoApply 后应回到代码默认(开)")
}
if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 {
t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg)
}
}
// tuneWaitProbe 是一个「听得懂 ctx 取消」的假等待:记录每次被要求的时长,并在
// ctx 结束时立刻返回 false —— 与生产的 `waitTidyTimer` 同语义。因此它既能被
// **调优唤醒**打断(唤醒 = 取消这一次等待的子 ctx),也能被 Stop 停住。
type tuneWaitProbe struct {
mu sync.Mutex
delays []time.Duration
calls chan time.Duration
}
func newTuneWaitProbe() *tuneWaitProbe {
return &tuneWaitProbe{calls: make(chan time.Duration, 32)}
}
func (p *tuneWaitProbe) wait(ctx context.Context, d time.Duration) bool {
p.mu.Lock()
p.delays = append(p.delays, d)
p.mu.Unlock()
select {
case p.calls <- d:
default:
}
select {
case <-ctx.Done():
return false
case <-time.After(30 * time.Second):
// 兜底:用例失败时才可能走到这里(正常路径都由取消叫醒)。
return false
}
}
// awaitCall 等下一次「等待」被调用并返回它要求的时长(超时即 FAIL)。
func (p *tuneWaitProbe) awaitCall(t *testing.T, step string) time.Duration {
t.Helper()
select {
case d := <-p.calls:
return d
case <-time.After(5 * time.Second):
t.Fatalf("等心跳走到「%s」超时(循环没走到预期阶段)", step)
return 0
}
}
// TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence 钉住**热生效的新机制**
// (唤醒,而不是 Stop/Start):
//
// ① 保存前那一觉是按**旧**周期算的(15:00 起、上次 14:30、周期 24h ⇒ 等 23h30m);
// ② 保存**当场**把那一觉作废并按新周期重排(下一觉 = 30m),不必等旧周期睡完;
// ③ 唤醒**不跑轮**(`lastRun` 不前移 —— 到没到点仍只由 dueNow 判);
// ④ 心跳**没有**被停过:`running` 仍为 true,等待由**同一个**循环继续(不是重启)。
func TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence(t *testing.T) {
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
// 未启动:保存只改配置,不碰心跳状态(也**不**该把它启动起来)。
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
t.Fatalf("ApplyPanelTuning(idle): %v", err)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if running {
t.Fatal("未 Start 时不该因为保存而启动心跳")
}
probe := newTuneWaitProbe()
svc.wait = probe.wait
svc.mu.Lock()
svc.lastRun = at.Add(-30 * time.Minute)
svc.mu.Unlock()
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
if got := probe.awaitCall(t, "第一觉"); got != 23*time.Hour+30*time.Minute {
t.Fatalf("第一觉 = %s, want 23h30m(按旧周期)", got)
}
// 保存:周期 24h → 1h。这一觉必须**当场**作废并重排到 30 分钟。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning(running): %v", err)
}
if got := probe.awaitCall(t, "被唤醒后的第二觉"); got != 30*time.Minute {
t.Fatalf("第二觉 = %s, want 30m(仍按旧周期说明没被唤醒)", got)
}
svc.mu.Lock()
last, runningAfter := svc.lastRun, svc.running
svc.mu.Unlock()
if !runningAfter {
t.Fatal("唤醒后心跳必须仍在跑(唤醒不是停表)")
}
if !last.Equal(at.Add(-30 * time.Minute)) {
t.Fatalf("唤醒跑了轮:lastRun = %v, want 14:30(唤醒只重排,不跑轮)", last)
}
}
// TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows 钉住「重排不碰生命周期」:
// 面板保存**不再**顺带触发一次到期清扫,也**不再**重启清扫的 ticker。
//
// 判据用行为而不是实现:保存前后各造一行已到期的记忆,保存之后它必须**还在**。
// 若保存走了 Stop/Start(早先那版就是),`expirySweepLoop` 的「启动即扫一轮」会
// 立刻把它删掉 —— 这正是塔要证伪的那条副作用。
func TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows(t *testing.T) {
ctx := context.Background()
now := tidyTestTime(t)
store := newTestStore(t)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
var (
mu sync.Mutex
audits []string
)
svc := newTidyServiceForTest(t, store, base, nil, fixedTidyClock(now),
func(_, what, result string) {
mu.Lock()
defer mu.Unlock()
audits = append(audits, what+" "+result)
})
// 假等待:把心跳停在 ctx 上(本轮只关心清扫那一路,心跳不搅局)。
svc.wait = func(ctx context.Context, _ time.Duration) bool {
<-ctx.Done()
return false
}
expireAudits := func() int {
mu.Lock()
defer mu.Unlock()
n := 0
for _, a := range audits {
if strings.HasPrefix(a, expireAuditVerb+" ") {
n++
}
}
return n
}
// ① 先证明清扫确实在跑:启动清扫会把这一行删掉。
first := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "启动清扫应当删掉它", ExpiresAt: now.Add(-time.Minute),
})
svc.Start(ctx)
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Stop(stopCtx)
})
waitUntilRowGone(t, store, first.ID)
afterStart := expireAudits()
// ② 再造一行已到期的,这次没有任何「启动清扫」来删它。
pending := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "面板保存不该把它删掉", ExpiresAt: now.Add(-time.Minute),
})
// ③ 面板保存(值真的变了,因此一定会走重排)。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
// 给「万一真被触发了」留出显形窗口:启动清扫只需一次 SQLite 往返(微秒级)。
time.Sleep(200 * time.Millisecond)
if !memoryRowExists(t, store, pending.ID) {
t.Fatal("面板保存触发了到期清扫(那行被删了)—— 重排不该碰生命周期")
}
if got := expireAudits(); got != afterStart {
t.Fatalf("面板保存后多出了清扫审计:%d ⇒ %d", afterStart, got)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if !running {
t.Fatal("面板保存不该把心跳停掉")
}
}
// TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变)
// 时不打扰心跳:那一次保存不该白白停一次心跳。
func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) {
t.Parallel()
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
same := normalizeTidyConfig(base)
if svc.cfg != same {
t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same)
}
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(noop): %v", err)
}
if got := svc.nextDelay(); got != 8*time.Hour {
t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got)
}
}
// TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样
// **重算**:清掉即回代码默认(中档结构性为空)。
func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) {
t.Parallel()
store := newTestStore(t)
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
// 面板关闸 + 1 小时 TTL。
panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)}
if err := reviewer.ApplyPanelTuning(panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("ingestGate:false 应当当场关掉同步入库闸")
}
if reviewer.cfg.TempTTL != time.Hour {
t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL)
}
// 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。
if reviewer.gateEnabled() {
t.Fatal("关闸后 gateEnabled 仍为 true")
}
// 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() {
t.Fatal("清掉 ingestGate 后应当回到默认(开)")
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want)
}
// 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil {
t.Fatalf("ApplyPanelTuning(bad ttl): %v", err)
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL)
}
// 审查本身的其它旋钮**一个都不动**(它们不是面板键)。
if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 {
t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg)
}
}
// TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储
// 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效;
// 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。
func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
t.Parallel()
store := newTestStore(t)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err)
}
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("Store.ApplyIngestPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("经 Store 的转调没有落到审查服务上")
}
}
// TestPreInsertReviewer_PanelTuningRaceWithGateReads 是**并发回归哨兵**(切片
// MEMPANEL / M4 的快修)。
//
// 背景:面板调优(`ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段
// (`DisableIngestGate` / `TempTTL`)。在这条特性之前 `cfg` 是构造期冻结的,读侧
// 无锁本来安全;有了运行期写入之后,凡是读这两个字段的地方都必须与写方同步 ——
// 否则就是「写方持锁、读方不持锁」的数据竞争。读侧两处分别是:
// - `gateEnabled()`(同步入库闸,记忆**写入路径**上调用);
// - `deferDeadline()`(第五态 `defer` 落 `expires_at`,写入路径与异步审查都会调)。
//
// 它**只在 `-race` 下有意义**:不带 `-race` 时它只是一次并发冒烟(两边都跑完即通过),
// 有没有竞争交给 race detector 判。跑法:
//
// go test -race -count=1 -run TestPreInsertReviewer_PanelTuningRaceWithGateReads ./internal/runtime/memory/
func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) {
store := newTestStore(t)
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
const rounds = 200
var wg sync.WaitGroup
wg.Add(3)
// 写方:模拟面板保存(每次改两个面板键)。
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
panel := runtimeconfig.MemoryTidyTuning{
IngestGate: tuningBool(i%2 == 0),
TempTtlSec: tuningInt(3600 + i),
}
if err := reviewer.ApplyPanelTuning(panel); err != nil {
t.Errorf("ApplyPanelTuning: %v", err)
return
}
}
}()
// 读方一:同步入库闸的开关判定(写入路径)。
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_ = reviewer.gateEnabled()
}
}()
// 读方二:第五态的到期时刻。
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_ = reviewer.deferDeadline(time.Now())
}
}()
wg.Wait()
}
// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。
//
// 背景(实测踩出来的,不是推演):面板调优会在运行期**整体覆写** `s.cfg`,因此该字段
// 的**每一个**读点都必须与写方同步。心跳那一路的读点(`nextDelay` / `dueNow`)现在走
// `scheduleSnapshot()`(同一把锁下同取「cfg + lastRun」)—— 这条哨兵钉的就是它:早先
// 那版把写入放在 `Start` **之后**,新起的循环立刻读 `s.cfg` 重算下一拍,与写入并排
// (而且那一拍还可能按**旧**配置算出来),`-race` 会当场报出来。
//
// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine
// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让
// **真心跳**当读者(连跑多轮,每轮都换一次周期),与生产形态一致。
//
// 与它同族的另两条哨兵:审查档(`PreInsertReviewer` 的并发读写)与试算/手动跑那两个
// **活端点**(`TestTidyService_PanelTuningRaceWithDryRunGuard`)。
//
// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法:
//
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithLiveHeartbeat ./internal/runtime/memory/
func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) {
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
const rounds = 25
for i := 0; i < rounds; i++ {
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Fatalf("ApplyPanelTuning(%d): %v", i, err)
}
}
// 每轮重排之后心跳都必须还活着(唤醒**不是**停表:走 Stop/Start 那版才会把
// running 置为 false,忘了重启就静默停摆 —— 现在这条路径上根本没有 Start/Stop),
// 且最后一轮的周期真的生效在运行期。
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if !running {
t.Fatal("连着保存多次之后心跳必须仍在跑")
}
if want := 3600 + rounds - 1; svc.cfg.IntervalSec != want {
t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want)
}
}
// TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵
// (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与
// **试算 / 手动跑两个活端点的开关判定**并发。
//
// 为什么单独要这一条:`TidyService.DryRun` 与 `TidyService.ManualRun` 的
// `if !s.enabled()` 都发生在**取 runMu 之前**,不受 runMu 保护 —— 它们正是
// `POST /admin/memories/tidy/dry-run`(含 `?async=1` 那条会起后台 goroutine、真机上
// 跑 90s 级的版本)与 `POST /admin/memories/tidy/run` 这条读点。只把「调度读」收进
// 持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。
//
// 形态是**两个墙钟窗口完全重叠**的连续调用(不是固定轮数)—— 这一点是被实测逼出来的:
// 早先用「写方 40 轮 / 读方 40 轮」时,写方那 40 次覆写在微秒级就跑完了,读方还在慢
// 端点里打转,于是「撤回 DryRun 同步」这个变异只红 4/8(`-count=1` 会漏掉约一半的
// 回归)。改成两侧各自跑满同一个 300ms 窗口后,变异 8/8 必红(实测数字见回报)。
//
// 写方每轮都换一个值:`ApplyPanelTuning` 对「配置没变」会早退(那是它的省事路径),
// 一直写同一个值就根本不会触发 `s.cfg` 的覆写,这条哨兵也就测不到东西。
//
// 它**只在 `-race` 下有意义**(不带 -race 时是一次并发冒烟 + 结果断言)。跑法:
//
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithDryRunGuard ./internal/runtime/memory/
func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) {
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
const raceWindow = 300 * time.Millisecond
stop := make(chan struct{})
var wg sync.WaitGroup
// 写方:连续覆写 s.cfg(每次换一个周期)。
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Errorf("ApplyPanelTuning: %v", err)
return
}
}
}()
// 读方:两个活端点各起两路(撞上写窗口的机会更多;两路都不改语义)。
reader := func(name string, call func() error) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if err := call(); err != nil {
t.Errorf("%s: %v", name, err)
return
}
}
}
dryRun := func() error {
if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
return err
}
return nil
}
manualRun := func() error {
if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
return err
}
return nil
}
wg.Add(4)
go reader("DryRun", dryRun)
go reader("DryRun", dryRun)
go reader("ManualRun", manualRun)
go reader("ManualRun", manualRun)
time.Sleep(raceWindow)
close(stop)
wg.Wait()
}
+90
View File
@@ -0,0 +1,90 @@
package vector
import (
"context"
"fmt"
"strings"
)
// 本文件是**向量删除**(索引的清除侧)。动因是一条实测 finding(切片 M1 的
// 读侧时效闸交下来的):临时记忆到期后**行不在了、向量还在** —— 过期行的向量
// 仍留在 vec_items 里,占掉 idx.Search 的 top-K 预算,于是「语义检索前 K 名」
// 里有一半是永远不会被返回的死条目(读侧闸已挡住那些行),**召回质量静默下降**。
// 到期清扫(internal/runtime/memory/expire.go)在物理删行时同步调这里,把
// 向量一并清掉。
//
// 🔴 **为什么不是 Index 的必需方法**(Deleter 与 Index 并列、由调用方类型断言):
// Index 是本包对外的稠密索引抽象,加一个必需方法会**立刻打破所有既有实现** ——
// 包括别的包里的测试桩(例如 memory 包的 mockVectorIndex)。清除能力因此按
// **可选扩展**建模:两种生产后端(SQLiteIndex / PGVectorIndex)都实现它,
// 只读的桩可以不实现,调用方断言失败时退化为「不清向量」并留一条日志。
// 这与本仓既有的可选能力手法一致(例如 `a.vindex.(*vector.PGVectorIndex)`)。
// Deleter 是 Index 的**可选扩展**:把已不存在的 id 从索引里清掉。id 不存在时
// 视为成功(删不存在的向量本就是空操作),因此重复调用是幂等的。
//
// 调用方(memory 包)在物理删行之后调用它;**不是**读路径的一部分。
type Deleter interface {
Delete(ctx context.Context, ids ...string) error
}
// deleteVecItemsSQLTemplate 是两种后端共用的删除语句骨架:`%s` 是占位串
// (SQLite `?` / pgvector `$1,$2,...`)。表名是编译期常量,绝不来自输入。
const deleteVecItemsSQLTemplate = `DELETE FROM vec_items WHERE id IN (%s)`
// vecDeletePlaceholders 为 ids 生成删除语句的占位串与参数表。`dollar` 为 true
// 时用 Postgres 的 `$n` 形式,否则用 SQLite 的 `?`。
//
// 空白 id 被**跳过**而不是报错:删一个不存在的向量是空操作,不该让整批失败
// (Add 拒绝空 id,是因为那会写出一行不可寻址的数据;删除没有这个问题)。
// 全部为空时返回空占位串,调用方据此直接返回。
func vecDeletePlaceholders(ids []string, dollar bool) (string, []any) {
args := make([]any, 0, len(ids))
for _, id := range ids {
if strings.TrimSpace(id) == "" {
continue
}
args = append(args, id)
}
if len(args) == 0 {
return "", nil
}
var b strings.Builder
for i := range args {
if i > 0 {
b.WriteByte(',')
}
if dollar {
fmt.Fprintf(&b, "$%d", i+1)
} else {
b.WriteByte('?')
}
}
return b.String(), args
}
// Delete 把 ids 对应的向量从 SQLite 索引里删掉。id 不存在时无事发生(幂等);
// 空 ids 是空操作。ctx 被取消时中止。
func (s *SQLiteIndex) Delete(ctx context.Context, ids ...string) error {
placeholders, args := vecDeletePlaceholders(ids, false)
if placeholders == "" {
return nil
}
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(deleteVecItemsSQLTemplate, placeholders), args...); err != nil {
return fmt.Errorf("memory/vector: delete %d sqlite vectors: %w", len(args), err)
}
return nil
}
// Delete 把 ids 对应的向量从 pgvector 索引里删掉。语义与 SQLiteIndex.Delete
// 逐字相同(幂等、空 ids 空操作、ctx 取消即中止),只用 `$n` 占位。
func (p *PGVectorIndex) Delete(ctx context.Context, ids ...string) error {
placeholders, args := vecDeletePlaceholders(ids, true)
if placeholders == "" {
return nil
}
if _, err := p.db.ExecContext(ctx, fmt.Sprintf(deleteVecItemsSQLTemplate, placeholders), args...); err != nil {
return fmt.Errorf("memory/vector: delete %d pg vectors: %w", len(args), err)
}
return nil
}
@@ -0,0 +1,184 @@
package vector
import (
"context"
"os"
"testing"
)
// 本文件锁定向量的**清除侧**(Deleter):到期清扫删行时同步删向量,否则过期
// 向量会占掉 idx.Search 的 top-K 预算(切片 M1 的 reviewer 交上来的 finding)。
// TestSQLite_Delete_正常 覆盖删除的正例与三条边界:删掉的查不到、没删的还在、
// 删不存在的 id 幂等成功、空 ids 空操作。
func TestSQLite_Delete_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 3)
for _, id := range []string{"cat", "dog", "bird"} {
if err := s.Add(ctx, id, Vector{1, 0, 0}); err != nil {
t.Fatalf("Add(%s): %v", id, err)
}
}
if err := s.Delete(ctx, "cat"); err != nil {
t.Fatalf("Delete(cat): %v", err)
}
hits, err := s.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
for _, h := range hits {
if h.ID == "cat" {
t.Fatal("被删的向量还在索引里")
}
}
if len(hits) != 2 {
t.Fatalf("剩余命中 = %d, want 2(只该删掉那一条)", len(hits))
}
// 幂等:再删一次同一个 id 不报错(删不存在的行本就是空操作)。
if err := s.Delete(ctx, "cat"); err != nil {
t.Fatalf("重复 Delete 应当幂等成功: %v", err)
}
// 空 ids / 全是空白的 id:空操作,不报错。
if err := s.Delete(ctx); err != nil {
t.Fatalf("Delete() 空 ids 应当是无操作: %v", err)
}
if err := s.Delete(ctx, " "); err != nil {
t.Fatalf("Delete(空白 id) 应当是无操作: %v", err)
}
// 批删:一次给两个 id 走的是同一条占位串路径。
if err := s.Delete(ctx, "dog", "bird"); err != nil {
t.Fatalf("批量 Delete: %v", err)
}
hits, err = s.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search after batch delete: %v", err)
}
if len(hits) != 0 {
t.Fatalf("批量删后仍有 %d 条命中", len(hits))
}
}
// TestSQLite_Delete_取消的ctx_异常:ctx 已取消时删除直接报错,绝不静默成功。
func TestSQLite_Delete_取消的ctx_异常(t *testing.T) {
t.Parallel()
s := newInMemorySQLite(t, 3)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.Delete(ctx, "cat"); err == nil {
t.Fatal("已取消的 ctx 下 Delete 应当报错")
}
}
// TestVecDeletePlaceholders 钉住占位串与参数表的形状:SQLite 用 `?`、pgvector
// 用 `$n`,空白 id 被跳过,全空白时返回空占位串。
func TestVecDeletePlaceholders(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ids []string
dollar bool
wantSQL string
wantN int
}{
{name: "sqlite 单个", ids: []string{"a"}, wantSQL: "?", wantN: 1},
{name: "sqlite 三个", ids: []string{"a", "b", "c"}, wantSQL: "?,?,?", wantN: 3},
{name: "pg 两个", ids: []string{"a", "b"}, dollar: true, wantSQL: "$1,$2", wantN: 2},
{name: "跳过空白", ids: []string{"a", "", " ", "b"}, dollar: true, wantSQL: "$1,$2", wantN: 2},
{name: "全空", ids: []string{"", " "}, wantSQL: "", wantN: 0},
{name: "无参数", ids: nil, wantSQL: "", wantN: 0},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sql, args := vecDeletePlaceholders(tc.ids, tc.dollar)
if sql != tc.wantSQL {
t.Fatalf("占位串 = %q, want %q", sql, tc.wantSQL)
}
if len(args) != tc.wantN {
t.Fatalf("参数数 = %d, want %d", len(args), tc.wantN)
}
})
}
}
// TestSQLite_Delete_底层错误_异常:库已关闭时删除如实报错(而不是静默留下一堆
// 永远删不掉的向量)。
func TestSQLite_Delete_底层错误_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openTestDB(t)
s, err := NewSQLite(ctx, db, 3, testLogger())
if err != nil {
t.Fatalf("NewSQLite: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
if err := s.Delete(ctx, "cat"); err == nil {
t.Fatal("库已关闭时 Delete 应当报错")
}
}
// TestPG_Delete_RequiresEnv 在设置了 DB_PG_URL 时跑一次真实的 pgvector 删除往返,
// 否则跳过(与 TestPG_RequiresEnv 同一口径:本环境默认没有 PG 服务)。
func TestPG_Delete_RequiresEnv(t *testing.T) {
url := os.Getenv("DB_PG_URL")
if url == "" {
t.Skip("DB_PG_URL not set; skipping real PostgreSQL+pgvector integration test")
}
ctx := context.Background()
idx, err := NewPG(ctx, url, 3, testLogger())
if err != nil {
t.Fatalf("NewPG: %v", err)
}
defer func() { _ = idx.Close() }()
if err := idx.Add(ctx, "delete-me", Vector{1, 0, 0}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := idx.Delete(ctx, "delete-me"); err != nil {
t.Fatalf("Delete: %v", err)
}
hits, err := idx.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
for _, h := range hits {
if h.ID == "delete-me" {
t.Fatal("pgvector 上被删的向量还在")
}
}
// 幂等。
if err := idx.Delete(ctx, "delete-me"); err != nil {
t.Fatalf("重复 Delete 应当幂等: %v", err)
}
// 空 ids / 空白 id 空操作。
if err := idx.Delete(ctx); err != nil {
t.Fatalf("空 ids: %v", err)
}
if err := idx.Delete(ctx, ""); err != nil {
t.Fatalf("空白 id: %v", err)
}
// 反例对照:确认上面的 Search 不是因为「整张表空了」才没有命中。
if err := idx.Add(ctx, "keep-me", Vector{1, 0, 0}); err != nil {
t.Fatalf("Add keep-me: %v", err)
}
hits, err = idx.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search keep-me: %v", err)
}
found := false
for _, h := range hits {
if h.ID == "keep-me" {
found = true
}
}
if !found {
t.Fatal("对照组失败:没删的向量也查不到,说明 Search 本身没工作")
}
}
@@ -0,0 +1,331 @@
package gateway
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理「面板调优」的**管理读写端点**(契约待塔登记,见回报):
//
// GET /api/v1/admin/memories/tidy/tuning 读:盘上的 7 键值 + 取值规则
// PUT /api/v1/admin/memories/tidy/tuning 写:三态补丁 + 原子落盘 + 热生效
//
// 它回答的是主人的两句原话(逐字,见 M4 mission Context):
//
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
// 面板配置文件。这些键优先从面板配置上取」
// 「频率必须调 可以调 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到了
// 静默期稍微长一点」
//
// 此前 `$DATA_DIR/config/memory-tidy.json` **只有读路径**(`cmd/cyrene/wire_memory_tidy.go`
// 在装配期读一次 + 启动播种):主人要改频率只能 SSH 改文件 + 重启后端。本切片把
// 「读回来 → 改 → 落盘」这条面板路径补上,**零新 env 键**(`.env` 已冻结,
// design/13 §2.1)、**不新开文件家族**。
//
// 四条不可走样的口径:
//
// 1. **三态**(硬要求,与契约里 `plugin.saveProvider` 同族语义):键**缺位** ⇒ 不动;
// **合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**。
// `null` 是这一族的清除哨兵(= 删掉该键、回到下一档兜底)。三态的实现与
// 「非法值不落盘」的证据在 `admin_memory_tuning_file.go` 的
// `parseMemoryTidyTuningPatch`(解析阶段就拒,落盘只在解析成功之后发生)。
// 2. **写端点带审计**(动作 `memory.tidy.tuning`),且**不改** `.env`、不重启后端。
// 3. **热生效**:保存后把新值当场叠到运行期(可选能力,见 MemoryTidyTuningApplier)。
// 能力不存在或失败时,响应 `hot:false` + `hotError`,面板**如实**说「已落盘、
// 要重启后端」——绝不把「落盘了」说成「生效了」。
// 4. **诚实回话**:写成功后的响应是**重新读盘**的结果(真值回显),不是「我以为
// 写成了什么」;盘上文件解不开时读/写**都**拒绝(`409 tuning_corrupt`)并说明
// 运行期此刻正回落 `.env`/代码默认。
//
// 权限:GET 与 PUT 都走 **writeAuth(owner|admin)**(与本族其它记忆端点
// `/admin/memories`、`POST /admin/memories/{id}/revert` 同档)——它是私有记忆面的
// 运行期旋钮,不是给 viewer 的监控投影。
// memoryTuningLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
const memoryTuningLogSource = "gateway/memory-tuning"
// memoryTuningAuditAction 是写端点落审计的动作名(与 memory.tidy.apply /
// memory.tidy.reject 同一族命名)。
const memoryTuningAuditAction = "memory.tidy.tuning"
// memoryTuningMaxBodyBytes 是写端点请求体的墙钟上限。这个文件是给人改的小配置
// (11 个标量键),正常请求体不到 200 字节;给上限只为让畸形/超大请求不占内存,
// 不是业务上限。
const memoryTuningMaxBodyBytes = 16 << 10
// MemoryTidyTuningApplier 是「把面板值当场叠到**整理自动档**」的可选能力。
//
// 它**不是**必需依赖:组合根注入的整理服务(`deps.MemoryTidy` = `*memory.TidyService`)
// 实现了它 ⇒ 保存后立刻生效;没实现 ⇒ 值照旧落盘、但要等后端重启,响应里
// `hot:false` 如实说出来(口径 3)。
//
// base 是**`.env` 层**(由 `memory.TidyConfigFromConfig` 从进程生效配置投影而来,
// 与 wireMemoryTidy 用的是同一个唯一实现点);panel 是刚落盘的那一份。实现必须按
// 「面板 > `.env` > 代码默认」逐键重算,且**缺位键保持 base 的值** —— 也就是
// wireMemoryTidy 当初那一遍的同一口径,只是可以随时再跑一次。
type MemoryTidyTuningApplier interface {
ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error
}
// MemoryIngestTuningApplier 同理,服务于**同步入库闸 / 入库前审查**一侧
// (`ingestGate` / `tempTtlSec`)——它的持有者是记忆存储,因此由 `deps.Memories`
// 那个出口解析(网关不为此新开依赖面)。
type MemoryIngestTuningApplier interface {
ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error
}
// AdminMemoryTidyTuningView 是 GET / PUT `/admin/memories/tidy/tuning` 的 data。
type AdminMemoryTidyTuningView struct {
// Path 是面板配置文件的落点(**相对 `$DATA_DIR`**,如 `config/memory-tidy.json`):
// 面板据此如实告诉主人改的是哪个文件,而不必把部署绝对路径下发。
Path string `json:"path"`
// Present 报告那份文件是否已存在且有内容。false = 还没播种(后端**下次启动**
// 会自动按当时生效值创建它,见 runtimeconfig.SeedMemoryTidy)。
Present bool `json:"present"`
// Hot 报告这次结果是否**当场生效**(true)。false = 已落盘,但要等后端重启 ——
// 面板必须照实说,不得显示成「已生效」。GET 里恒为 false:只读不改变任何东西,
// 「生效」这个问题在只读路径上没有意义。
Hot bool `json:"hot"`
// HotError 在「已保存、但没能在运行期立刻生效」时给出原因(`hot:false` 时才有)。
HotError string `json:"hotError,omitempty"`
// Specs 是这 7 个键的**取值规则**(类型/单位/上下界/兜底来源),随读下发 ⇒
// 面板的表单与就地校验都由它驱动,不会出现「面板允许、后端 400」的两套规则。
Specs []memoryTidyTuningSpec `json:"specs"`
// Values 是**盘上**的值:键缺位 ⇒ 整个键不出现(= 跟随兜底),不编一个 0 ——
// 「写了 0」(坏值,运行期会 WARN 回落)与「没写」在面板上必须看得出区别。
Values map[string]any `json:"values"`
// EnvValues 是缺位时**下一档(`.env`)**的进程生效值(只读下发):面板据此写
// 「留空 ⇒ 跟随 `.env` 的 86400」,而不必自己猜一个数。与
// GET /admin/config-facts 同源(都取 `s.cfg`,网关绝不直接 fopen `.env`)。
// 某个键**连 `.env` 档也没有值**(未填 / 结构性无此键)时它不出现。
EnvValues map[string]int `json:"envValues,omitempty"`
}
// adminMemoryTidyTuningRead 是 GET 的处理函数:只读、不审计(与
// /admin/config-facts、tidy 试算同一档:读事实不改状态)。
func (s *Server) adminMemoryTidyTuningRead(w http.ResponseWriter, _ *http.Request) {
path, ok := s.memoryTidyTuningPath()
if !ok {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
return
}
view, err := s.buildMemoryTidyTuningView(path)
if err != nil {
s.writeTuningCorrupt(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// adminMemoryTidyTuningWrite 是 PUT 的处理函数:三态补丁 → 原子落盘 → 热生效 →
// 审计。顺序是刻意的:**校验全部发生在落盘之前**(口径 1),落盘之后才谈生效。
func (s *Server) adminMemoryTidyTuningWrite(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, memoryTuningMaxBodyBytes))
if err != nil {
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
return
}
patch, err := parseMemoryTidyTuningPatch(body)
if err != nil {
// 非法值/未知键:**盘上一个字节都不动**(解析阶段就退出,写盘在下面)。
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
return
}
path, ok := s.memoryTidyTuningPath()
if !ok {
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
return
}
// 落盘(补丁 + 原子写)交给 runtimeconfig 家族:它同时是「盘上那份能不能被
// 运行期读走」的**唯一**判定点(读不走的文件拒绝覆盖,见 ErrInvalidMemoryTidy)。
if !patch.empty() {
if err := s.commitMemoryTidyTuningPatch(path, patch); err != nil {
s.auditClaims(r, memoryTuningAuditAction, "error")
s.writeTuningWriteFailure(w, err)
return
}
}
view, err := s.buildMemoryTidyTuningView(path)
if err != nil {
// 刚落盘就解不开:这是本端点的编程/磁盘错误,如实回 500,绝不回一个
// 「已保存」的假成功。
s.auditClaims(r, memoryTuningAuditAction, "error")
s.writeTuningCorrupt(w, err)
return
}
hot, hotErr := s.applyMemoryTidyTuningHot(path)
view.Hot = hot
if hotErr != nil {
view.HotError = hotErr.Error()
}
s.auditClaims(r, memoryTuningAuditAction, "ok")
writeJSON(w, http.StatusOK, view)
}
// commitMemoryTidyTuningPatch 把补丁交给 runtimeconfig 家族原子落盘。值一个都没变
// 时不落盘(「保存同样的值」不该在盘上留一次无意义的改写)。
func (s *Server) commitMemoryTidyTuningPatch(path string, patch memoryTidyTuningPatch) error {
_, wrote, err := runtimeconfig.PatchMemoryTidyTuning(path, patch.set, patch.clear)
if err != nil {
return err
}
log := logger.WithSource(s.log, memoryTuningLogSource)
if !wrote {
log.Info("memory tidy tuning unchanged (no bytes written)", "path", path, "keys", patch.changed)
return nil
}
log.Info("memory tidy tuning saved", "path", path, "keys", patch.changed)
return nil
}
// buildMemoryTidyTuningView 读盘并组装面板视图(口径 4:响应就是**盘上真值**)。
func (s *Server) buildMemoryTidyTuningView(path string) (AdminMemoryTidyTuningView, error) {
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return AdminMemoryTidyTuningView{}, err
}
present, err := memoryTidyTuningPresent(path)
if err != nil {
return AdminMemoryTidyTuningView{}, err
}
return AdminMemoryTidyTuningView{
Path: runtimeconfig.MemoryTidyConfigRelPath,
Present: present,
Specs: memoryTidyTuningSpecs(),
Values: memoryTidyTuningValues(tuning),
EnvValues: s.memoryTidyTuningEnvValues(),
}, nil
}
// memoryTidyTuningPresent 报告面板配置文件是否已存在且**有内容**(全空白 = 还没配过,
// 与 runtimeconfig.LoadMemoryTidyTuning 的口径一致)。
func memoryTidyTuningPresent(path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("读取 %s:%w", path, err)
}
return len(strings.TrimSpace(string(raw))) > 0, nil
}
// memoryTidyTuningEnvValues 返回缺位时的**下一档(`.env`)**进程生效值。
//
// 只取 `s.cfg`(配置层已消化「进程环境 → `.env` → 声明默认」):网关**绝不**
// 直接 fopen `.env`,否则面板看到的是文件原文而不是运行实例在用的数。
//
// 三处刻意省略(省略 = 「这一档也没值,继续落到代码默认」,而不是编一个 0):
// - `MEMORY_TIDY_INTERVAL_SEC` ≤ 0:非正数是坏值(运行期会 WARN 并回落),
// 报出来会让人以为「留空就得到 0 秒」;
// - 安静时段两端**都为 0**:0/0 是「未填」,运行期会回落到设计默认窗口,
// 此时报 0/0 等于把「未填」谎报成一个午夜窗口。
//
// 其余四个键(`quietIntervalSec` / `ingestGate` / `autoApply` / `tempTtlSec`)在
// `.env` 里**结构性没有出处**(`.env` 已冻结,design/13 §2.1),因此不存在第二档,
// 它们的下一档就是代码默认(specs 里的 fallback 已说明)。
func (s *Server) memoryTidyTuningEnvValues() map[string]int {
out := map[string]int{}
if s.cfg == nil {
return out
}
if v := s.cfg.MemoryTidy.IntervalSec; v > 0 {
out["intervalSec"] = v
}
if start, end := s.cfg.MemoryTidy.QuietStartHour, s.cfg.MemoryTidy.QuietEndHour; start != 0 || end != 0 {
out["quietStartHour"] = start
out["quietEndHour"] = end
}
return out
}
// memoryTidyTuningPath 解析面板配置文件的绝对落点(`$DATA_DIR/config/memory-tidy.json`)。
//
// 数据目录的来源与图片上传端点同一口径:优先已解析的 `deps.DataDir`,为空时回退
// `cfg.DataDir`;两者都空(未接线)时报 false,调用方如实回 `tuning_unavailable`,
// 绝不猜一个目录去写。
func (s *Server) memoryTidyTuningPath() (string, bool) {
dataDir := s.dataDir
if dataDir == "" && s.cfg != nil {
dataDir = s.cfg.DataDir
}
if dataDir == "" {
return "", false
}
return runtimeconfig.DefaultMemoryTidyPath(dataDir), true
}
// applyMemoryTidyTuningHot 把刚落盘的面板值当场叠到运行期(口径 3)。
//
// 返回 (hot, err):hot=false 表示这次**没能在运行期生效**(能力未接线,或叠的过程
// 报错),err 说明原因。两者只用于如实回话,**不影响**「已经落盘」这个事实 ——
// 值不会因为热生效失败而丢。
//
// 能力用类型断言探测(可选能力,与 `plugin` 侧的可选接口同一手法):网关**不为此**
// 新增 Deps 字段/Server 字段 —— 整理服务本来就在 `s.memoryTidy` 里,审查器挂在
// `deps.Memories` 的实现上。这样「有没有热生效」是一件可观测的事实,而不是一句
// 靠不住的断言。
func (s *Server) applyMemoryTidyTuningHot(path string) (hot bool, hotErr error) {
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return false, fmt.Errorf("重读面板配置失败:%v", err)
}
applied := false
if applier, ok := s.memoryTidy.(MemoryTidyTuningApplier); ok {
base := memory.TidyConfig{}
if s.cfg != nil {
base = memory.TidyConfigFromConfig(s.cfg.MemoryTidy)
}
if err := applier.ApplyPanelTuning(base, tuning); err != nil {
return false, fmt.Errorf("整理自动档未能热生效:%v", err)
}
applied = true
}
if applier, ok := s.memories.(MemoryIngestTuningApplier); ok {
if err := applier.ApplyIngestPanelTuning(tuning); err != nil {
return false, fmt.Errorf("入库审查未能热生效:%v", err)
}
applied = true
}
if !applied {
return false, fmt.Errorf("运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效")
}
return true, nil
}
// writeTuningCorrupt 把「盘上那份读不走」映射成 409 tuning_corrupt,并把**运行期的
// 实际后果**写进文案:此刻它正整体回落 `.env`/代码默认(见 runtimeconfig 的读取口径),
// 而面板**拒绝**在这个文件上覆盖 —— 坏文件请人工修复或删除。
func (s *Server) writeTuningCorrupt(w http.ResponseWriter, cause error) {
logger.WithSource(s.log, memoryTuningLogSource).Warn(
"memory tidy tuning file unusable", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
writeError(w, apperr.Wrap(apperr.ErrConflict, "tuning_corrupt",
"面板配置文件 %s 解不开(运行期正回落 .env / 代码默认);面板拒绝在一个读不走的文件上覆盖,请人工修复或删除它:%v",
runtimeconfig.MemoryTidyConfigRelPath, cause))
}
// writeTuningWriteFailure 把落盘失败如实分类:**盘上那份读不走**(ErrInvalidMemoryTidy)
// 是 409(拒绝覆盖,不是故障),其余(IO/权限/重读不一致)是 500。两种都带出处,
// 绝不回一个「已保存」的假成功。
func (s *Server) writeTuningWriteFailure(w http.ResponseWriter, cause error) {
if errors.Is(cause, runtimeconfig.ErrInvalidMemoryTidy) {
s.writeTuningCorrupt(w, cause)
return
}
logger.WithSource(s.log, memoryTuningLogSource).Error(
"memory tidy tuning write failed", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_write_failed",
"面板配置文件 %s 落盘失败(盘上内容未改动):%v", runtimeconfig.MemoryTidyConfigRelPath, cause))
}
@@ -0,0 +1,278 @@
package gateway
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是记忆整理「面板调优」的**键规格与三态校验**层(文件读写与原子落盘在
// `internal/config/runtimeconfig` 家族里,见 memorytidy_save.go —— 落点是那一族的
// 职责,网关只负责 API 契约这一侧的取值规则)。
//
// 它管的是 `$DATA_DIR/config/memory-tidy.json` 里主人能调的 7 个旋钮(design/13 §2.1:
// 调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码默认值,不进 `.env`)。
//
// 三态与清除哨兵(与契约里 `plugin.saveProvider` 同族语义):
// - 键**没出现** ⇒ 不动它(`changed` 里也不出现);
// - 值为 `null` ⇒ 从面板文件里**删掉**这个键(回到下一档兜底:`.env` → 代码默认);
// - 值合法 ⇒ 采用;
// - 值非法(类型不对 / 超界 / 不是 JSON 对象 / 有不认识的键)⇒ 一律**在解析阶段**
// 被拒(调用方回 400 且盘上一个字节都不动),**绝不静默忽略** —— 静默忽略会让
// 主人以为改了而实际没改(把 `quietEndHour` 拼成 `quietEnd` 就正是这种形态)。
const (
// tuningKindInt / tuningKindBool 是本切片管得到的两种键型。
tuningKindInt = "int"
tuningKindBool = "bool"
// tuningUnitSec / tuningUnitHour 是 int 键的单位(面板据此写「秒 / 点」)。
tuningUnitSec = "sec"
tuningUnitHour = "hour"
// tuningSecMin 是三个秒键的下界:**必须为正**(≤0 的周期不是「没配」,而是一个
// 会让整理退化成空转的值;运行期口径同样把非正数当坏值回落,见
// memory.ApplyTidyScheduleTuning 与 runtimeconfig.MemoryTidyTuning 的注释)。
tuningSecMin = 1
// tuningHourMin / tuningHourMax 是钟点键的取值域(本地钟点 0..23)。
tuningHourMin = 0
tuningHourMax = 23
// tuningCodeDefault 是「缺位时落到代码默认」这一类兜底来源的标识(面板文案用)。
// 它**只说来源、不带数值**:数值的唯一真源在 memory 侧(normalizeTidyConfig /
// normalizeReviewConfig),网关不另抄一份默认值 —— 抄一份就是第二个真源。
tuningCodeDefault = "code-default"
)
// memoryTidyTuningSpec 是一个可写键的**取值规则**(唯一真源)。
//
// 它既驱动写侧校验,也随 GET 下发给面板(`specs`):面板据此渲染表单与就地报错,
// 因此不会出现「面板允许、后端 400」的两套规则(与 /admin/llm 下发
// reasoningKinds / personaProviderOptions 同一手法)。
type memoryTidyTuningSpec struct {
Key string `json:"key"`
// Kind ∈ int | bool;面板据此选输入控件类型。
Kind string `json:"kind"`
// Unit ∈ sec | hour(仅 int 键):面板据此写单位,不自己猜。
Unit string `json:"unit,omitempty"`
// Min / Max 是闭区间上下界(缺省 = 该端无约束)。
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
// Fallback 报告这个键**缺位(或面板 `null` 清除)时**生效值从哪来:
// - `env:MEMORY_TIDY_*` ⇒ 回落 `.env` 的同名键(进程生效值见
// GET /admin/config-facts 的同名事实行);
// - `panel:<key>` ⇒ 回落面板文件里另一个键;
// - `code-default` ⇒ 回落代码默认。
// 它只给**来源**,不给数值:数值的真源在各自那一档(见 tuningCodeDefault)。
Fallback string `json:"fallback"`
}
// memoryTidyTuningSpecs 是面板可写 7 键的**唯一清单**(顺序 = 面板显示顺序)。
//
// 为什么是这 7 个:主人要能调**整理频率**(白天短、静默期长)与**入库审查的两个
// 开关/时长**,而 `.env` 已冻结(design/13 §2.1)⇒ 这些旋钮只能落面板配置文件,
// 面板必须能**写**它。
//
// 刻意**不在**这里的两类键:
// - `t1Judge` / `t1PerWindow` / `t1WindowSec` / `autoApplyPerRound`:同文件的其它
// 旋钮,本切片的面板卡片不呈现,写端点也不接受它们(未知键一律 400,免得主人
// 以为改了、其实没动)。它们仍**原样保留**在文件里(补丁语义)。
func memoryTidyTuningSpecs() []memoryTidyTuningSpec {
secMin := tuningSecMin
hourMin := tuningHourMin
hourMax := tuningHourMax
return []memoryTidyTuningSpec{
{
Key: "intervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin,
Fallback: "env:MEMORY_TIDY_INTERVAL_SEC",
},
{
Key: "quietIntervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin,
Fallback: "panel:intervalSec",
},
{
Key: "quietStartHour", Kind: tuningKindInt, Unit: tuningUnitHour,
Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_START_HOUR",
},
{
Key: "quietEndHour", Kind: tuningKindInt, Unit: tuningUnitHour,
Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_END_HOUR",
},
{Key: "ingestGate", Kind: tuningKindBool, Fallback: tuningCodeDefault},
{Key: "autoApply", Kind: tuningKindBool, Fallback: tuningCodeDefault},
{Key: "tempTtlSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: tuningCodeDefault},
}
}
// memoryTidyTuningManagedKeys 返回可写键的键名(specs 顺序),供错误文案与审计用。
func memoryTidyTuningManagedKeys() []string {
specs := memoryTidyTuningSpecs()
out := make([]string, 0, len(specs))
for _, s := range specs {
out = append(out, s.Key)
}
return out
}
// memoryTidyTuningPatch 是一次写请求**已校验**的补丁。
//
// set / clear 是交给 runtimeconfig.PatchMemoryTidyTuning 的动作(clear = 删掉该键
// ⇒ 回到下一档兜底);changed 是本次真正出现的键(specs 顺序,含被清除的),空表示
// 一个键都没提交(此时**不写盘**,文件字节保持不变)。
type memoryTidyTuningPatch struct {
set map[string]json.RawMessage
clear []string
changed []string
}
// empty 报告这次请求没有提交任何受管的键。
func (p memoryTidyTuningPatch) empty() bool { return len(p.changed) == 0 }
// parseMemoryTidyTuningPatch 把请求体解析成一次已校验的补丁(三态的唯一实现点)。
//
// 未知键也归入「非法」:本端点**不静默忽略**任何提交上来的键 —— 静默忽略会让主人
// 以为改了而实际没改。
func parseMemoryTidyTuningPatch(body []byte) (memoryTidyTuningPatch, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return memoryTidyTuningPatch{}, fmt.Errorf("请求体必须是一个 JSON 对象:%w", err)
}
if raw == nil {
// JSON 的 `null` 解到 map 上是 nil 且**不报错**:它不是一个「提交了 0 个键」
// 的请求,而是一个形状不对的请求,如实拒掉(空补丁请用 `{}`)。
return memoryTidyTuningPatch{}, errors.New("请求体必须是一个 JSON 对象(收到 null)")
}
specs := memoryTidyTuningSpecs()
known := make(map[string]memoryTidyTuningSpec, len(specs))
for _, s := range specs {
known[s.Key] = s
}
if err := rejectUnknownTuningKeys(raw, known); err != nil {
return memoryTidyTuningPatch{}, err
}
patch := memoryTidyTuningPatch{set: map[string]json.RawMessage{}}
for _, s := range specs {
value, ok := raw[s.Key]
if !ok {
continue
}
patch.changed = append(patch.changed, s.Key)
if isJSONNull(value) {
patch.clear = append(patch.clear, s.Key)
continue
}
if err := validateTuningValue(s, value); err != nil {
return memoryTidyTuningPatch{}, err
}
patch.set[s.Key] = value
}
return patch, nil
}
// rejectUnknownTuningKeys 拒绝请求体里任何不受管的键(含拼错的键)。
func rejectUnknownTuningKeys(raw map[string]json.RawMessage, known map[string]memoryTidyTuningSpec) error {
unknown := make([]string, 0)
for k := range raw {
if _, ok := known[k]; !ok {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
return fmt.Errorf("不认识的键:%s(本端点只写 %s;同文件其它旋钮请人工编辑)",
strings.Join(unknown, "、"), strings.Join(memoryTidyTuningManagedKeys(), "、"))
}
// validateTuningValue 校验一个键的值(类型 + 取值域)。错误文案直接回给面板显示,
// 因此带上**收到的原始值**,让主人一眼看出该改什么。
func validateTuningValue(spec memoryTidyTuningSpec, raw json.RawMessage) error {
switch spec.Kind {
case tuningKindBool:
var b bool
if err := json.Unmarshal(raw, &b); err != nil {
return fmt.Errorf("%s 必须是 true 或 false(收到 %s)", spec.Key, clipJSON(raw))
}
return nil
case tuningKindInt:
var n int
if err := json.Unmarshal(raw, &n); err != nil {
return fmt.Errorf("%s 必须是整数(收到 %s)", spec.Key, clipJSON(raw))
}
if spec.Min != nil && n < *spec.Min {
return fmt.Errorf("%s 必须 ≥ %d(%s),收到 %d", spec.Key, *spec.Min, tuningUnitLabel(spec), n)
}
if spec.Max != nil && n > *spec.Max {
return fmt.Errorf("%s 必须 ≤ %d(%s),收到 %d", spec.Key, *spec.Max, tuningUnitLabel(spec), n)
}
return nil
default:
// 规格表自己写错了型别(编程错误,不是客户端错):如实报出来,不猜一种校验。
return fmt.Errorf("%s 的键规格未定义(kind=%q)", spec.Key, spec.Kind)
}
}
// tuningUnitLabel 返回单位的中文说明(错误文案用)。
func tuningUnitLabel(spec memoryTidyTuningSpec) string {
switch spec.Unit {
case tuningUnitSec:
return "秒"
case tuningUnitHour:
return "本地钟点 0-23"
default:
return "整数"
}
}
// tuningValueClipRunes 是错误文案里回显「收到的值」的 rune 上限。
const tuningValueClipRunes = 40
// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。
//
// 🔴 按 **rune** 截断,**不按字节**:值里完全可能是中文(主人就爱填中文),按字节切
// 会把一个多字节字符切成两半 —— 回显里因此出现半截 UTF-8(JSON 编码后变成 U+FFFD
// 替换符),而这条文案的用途正是让人**看清自己填错了什么**。手法与同包的
// `clipRunes`(notifications.go)同源,直接复用而不是再写一份。
func clipJSON(raw json.RawMessage) string {
return clipRunes(strings.TrimSpace(string(raw)), tuningValueClipRunes)
}
// isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。
func isJSONNull(raw json.RawMessage) bool {
return strings.TrimSpace(string(raw)) == "null"
}
// memoryTidyTuningValues 把盘上的调优结构投影成面板要的 7 键值表:**键缺位 ⇒ 整个
// 键不出现**(面板据此渲染「未设置、跟随兜底」),而不是回一个编出来的 0 ——
// 「0 秒」与「没配」在面板上必须看得出区别。
//
// 它刻意**不**回显同文件的其它键(t1Judge 等):那些不在本卡片的编辑范围,混进来
// 只会让人以为这张卡能改它们。
func memoryTidyTuningValues(t runtimeconfig.MemoryTidyTuning) map[string]any {
out := map[string]any{}
if t.IntervalSec != nil {
out["intervalSec"] = *t.IntervalSec
}
if t.QuietIntervalSec != nil {
out["quietIntervalSec"] = *t.QuietIntervalSec
}
if t.QuietStartHour != nil {
out["quietStartHour"] = *t.QuietStartHour
}
if t.QuietEndHour != nil {
out["quietEndHour"] = *t.QuietEndHour
}
if t.IngestGate != nil {
out["ingestGate"] = *t.IngestGate
}
if t.AutoApply != nil {
out["autoApply"] = *t.AutoApply
}
if t.TempTtlSec != nil {
out["tempTtlSec"] = *t.TempTtlSec
}
return out
}
@@ -0,0 +1,316 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件是记忆整理「面板调优」写端点的**守卫用例**(helper 与读侧用例见
// admin_memory_tuning_test.go):三态里最要紧的那两条(非法值绝不落盘、坏文件绝不
// 覆盖)与热生效的两种结果(成功 / 如实报失败)都在这里逐条钉死。
//
// 拆成两个文件只为守 AGENTS §10 的文件长度上限,口径与另一半完全同源。
// TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk 三态之三(本文件的核心
// 证据):任何非法输入都 `400 invalid_payload`,且**盘上一个字节都不动**。
func TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk(t *testing.T) {
t.Parallel()
cases := []struct{ name, body string }{
{"字符串冒充整数", `{"intervalSec": "3600"}`},
{"小数不是整数", `{"tempTtlSec": 1.5}`},
{"周期为 0(会让整理空转)", `{"intervalSec": 0}`},
{"周期为负", `{"quietIntervalSec": -60}`},
{"钟点越上界", `{"quietStartHour": 24}`},
{"钟点越下界", `{"quietEndHour": -1}`},
{"钟点不是数字", `{"quietEndHour": "07"}`},
{"开关不是布尔", `{"ingestGate": "yes"}`},
{"未知键(拼错的 quietEnd)", `{"quietEnd": 7}`},
{"请求体不是对象", `[1, 2]`},
{"请求体是 null", `null`},
{"请求体是空串", ``},
{"合法键与非法键同时提交", `{"intervalSec": 3600, "autoApply": "on"}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{
"intervalSec": 86400,
"quietStartHour": 23,
"quietEndHour": 7,
"t1Judge": false
}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), tc.body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
})
}
// 盘上**没有**文件时,非法输入也不得创建它(「先落盘再校验」同样是落盘)。
t.Run("文件不存在时也不创建", func(t *testing.T) {
env := newTuneEnv(t, Deps{})
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 0}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if _, err := os.Stat(env.path()); !os.IsNotExist(err) {
t.Fatalf("非法输入创建了文件:err=%v", err)
}
})
}
// TestAdminMemoryTidyTuningWrite_NullClearsKey 清除哨兵:`null` 删掉该键(回到下一档
// 兜底),同文件其它键不受影响。
func TestAdminMemoryTidyTuningWrite_NullClearsKey(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600, "quietIntervalSec": 7200, "t1Judge": false}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": null}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("clear status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if _, ok := view.Values["intervalSec"]; ok {
t.Fatalf("清除后不该再回显 intervalSec:%#v", view.Values)
}
if got := view.Values["quietIntervalSec"]; got != float64(7200) {
t.Fatalf("清除一个键影响了别的键:%#v", view.Values)
}
doc := env.diskDoc(t)
if _, ok := doc["intervalSec"]; ok {
t.Fatalf("盘上仍有 intervalSec:%s", string(env.bytes(t)))
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("清除一个键抹掉了别的键:%s", string(env.bytes(t)))
}
}
// TestAdminMemoryTidyTuningWrite_RefusesCorruptFile 盘上那份运行期读不走时,**读与写
// 都**拒绝(`409 tuning_corrupt`)且绝不覆盖 —— 否则面板会说「已保存」而运行期仍在
// 整体回落 `.env` 并记 WARN。
func TestAdminMemoryTidyTuningWrite_RefusesCorruptFile(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, body string }{
{"类型写错", `{"intervalSec": "3600"}`},
{"根本不是 JSON", `not json at all`},
{"顶层不是对象", `[1, 2, 3]`},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newTuneEnv(t, Deps{})
before := env.seed(t, tc.body)
tok := env.token(t, auth.RoleOwner)
resp, body := env.put(t, tok, `{"intervalSec": 3600}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("write status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "tuning_corrupt")
if string(env.bytes(t)) != string(before) {
t.Fatalf("坏文件被覆盖了:%s", string(env.bytes(t)))
}
// 读侧同样如实报坏,而不是回一份空配置假装「没配过」。
rresp, rbody := env.get(t, tok)
if rresp.StatusCode != http.StatusConflict {
t.Fatalf("read status = %d, want 409 (body=%s)", rresp.StatusCode, rbody)
}
assertErrorCode(t, rbody, "tuning_corrupt")
})
}
}
// TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite 「保存同样的值」不该在盘上留
// 一次无意义的改写(内容逐字节相同 ⇒ 不落盘、mtime 不动)。
func TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600}`)
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(env.path(), past, past); err != nil {
t.Fatalf("chtimes: %v", err)
}
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 3600}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
info, err := os.Stat(env.path())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().Equal(past) {
t.Fatalf("同值保存重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
}
if string(env.bytes(t)) != `{"intervalSec": 3600}` {
t.Fatalf("盘上内容变了:%s", string(env.bytes(t)))
}
}
// fakeTuningTidy 既是整理服务的只读出口,也是「可热生效」的实现(记录收到的值)。
type fakeTuningTidy struct {
fakeTidyDryRunner
gotBase *memory.TidyConfig
gotTuning *runtimeconfig.MemoryTidyTuning
err error
}
func (f *fakeTuningTidy) ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
f.gotBase = &base
f.gotTuning = &panel
return f.err
}
// fakeTuningMemories 同时实现 MemoryStore 与「入库侧可热生效」。
type fakeTuningMemories struct {
got *runtimeconfig.MemoryTidyTuning
err error
}
func (f *fakeTuningMemories) List(context.Context) ([]memory.MemoryItem, error) {
return []memory.MemoryItem{}, nil
}
func (f *fakeTuningMemories) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
f.got = &panel
return f.err
}
// TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits 接线了热生效实现时:保存后当场把
// **新值**交给运行期(hot=true),并落一条审计 `memory.tidy.tuning`/ok。
func TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits(t *testing.T) {
t.Parallel()
tidy := &fakeTuningTidy{}
mems := &fakeTuningMemories{}
env := newTuneEnv(t, Deps{MemoryTidy: tidy, Memories: mems})
env.seed(t, `{"intervalSec": 86400}`)
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{"intervalSec": 10800, "tempTtlSec": 259200}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if !view.Hot || view.HotError != "" {
t.Fatalf("hot/hotError = %v/%q, want true/空", view.Hot, view.HotError)
}
if tidy.gotTuning == nil || tidy.gotTuning.IntervalSec == nil || *tidy.gotTuning.IntervalSec != 10800 {
t.Fatalf("整理档没收到面板值:%#v", tidy.gotTuning)
}
if mems.got == nil || mems.got.TempTtlSec == nil || *mems.got.TempTtlSec != 259200 {
t.Fatalf("入库侧没收到面板值:%#v", mems.got)
}
// base 是 `.env` 层(进程生效配置投影),不是面板值本身。
if tidy.gotBase == nil || tidy.gotBase.IntervalSec != 86400 {
t.Fatalf("base 不是 .env 层:%#v", tidy.gotBase)
}
recs, err := env.srv.obs.audit.List(context.Background(), 50)
if err != nil {
t.Fatalf("list audit: %v", err)
}
found := false
for _, rec := range recs {
if rec.What == memoryTuningAuditAction && rec.Result == "ok" {
found = true
}
}
if !found {
t.Fatalf("审计链里没有 %s/ok 的记录:%#v", memoryTuningAuditAction, recs)
}
}
// TestAdminMemoryTidyTuningWrite_HotFailureIsHonest 热生效实现报错时:**值照旧留在盘上**
// (不丢),但响应如实 `hot:false` + 原因,绝不说成「已生效」。
func TestAdminMemoryTidyTuningWrite_HotFailureIsHonest(t *testing.T) {
t.Parallel()
tidy := &fakeTuningTidy{err: errors.New("boom")}
env := newTuneEnv(t, Deps{MemoryTidy: tidy})
env.seed(t, `{"intervalSec": 86400}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 10800}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Hot || view.HotError == "" {
t.Fatalf("hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
}
if got := view.Values["intervalSec"]; got != float64(10800) {
t.Fatalf("热生效失败不该丢值:%#v", view.Values)
}
}
// TestAdminMemoryTidyTuning_UnavailableWithoutDataDir 数据目录未接线时如实报「没有落点」,
// 绝不猜一个目录去写(端点据此回 500 tuning_unavailable)。
func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
t.Parallel()
srv, err := New(cfgDefault(), Deps{
Auth: auth.New(config.AuthConfig{AdminUser: "a", AdminPass: "b", JWTSecret: testSecret}),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
if _, ok := srv.memoryTidyTuningPath(); ok {
t.Fatal("没有数据目录时不该解析出落点")
}
}
// TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe 「非法值」的文案会把**收到的
// 值**回显出来(这是它的用途:让人看清自己填错了什么),而主人完全可能填中文 ——
// 回显必须按 rune 截断:按**字节**切会把一个汉字切成半截(非法 UTF-8,经 JSON 编码后
// 变成 U+FFFD 替换符),等于把「你填了什么」显示成乱码。
//
// 取值刻意让截断点**落在多字节字符中间**:JSON 值自带引号(1 字节),再让首个字符是
// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,于是 s[:40] 覆盖「引号 + a +
// 12 个完整汉字」(2 + 36 = 38 字节)再切掉第 13 个汉字的**第三个字节**(它的前两个
// 字节被留下)⇒ 那半个汉字成了非法 UTF-8。若只堆汉字(引号 1 + 3n 字节),40 会恰好
// 落在 rune 边界上,那样这条用例会**假绿**(实测过:按字节截断也照样通过)。
func TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe(t *testing.T) {
t.Parallel()
// ① 纯函数层:截断结果必须是合法 UTF-8(这一条与 JSON 层无关,最直接)。
long := json.RawMessage(`"a` + strings.Repeat("三", 60) + `"`)
clipped := clipJSON(long)
if !utf8.ValidString(clipped) {
t.Fatalf("clipJSON 切出了非法 UTF-8:%q", clipped)
}
if strings.ContainsRune(clipped, utf8.RuneError) {
t.Fatalf("clipJSON 结果里有替换符:%q", clipped)
}
// ② 端点层:400 文案里不许出现替换符,且开头几个字仍要如实回显。
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{"intervalSec": 86400}`)
payload := `a` + strings.Repeat("三", 60)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"quietStartHour": "`+payload+`"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error: %v (body=%s)", err, body)
}
if !utf8.ValidString(eobj.Message) || strings.ContainsRune(eobj.Message, utf8.RuneError) {
t.Fatalf("回显里出现了替换符/非法 UTF-8(半个汉字被切了):%q", eobj.Message)
}
if !strings.Contains(eobj.Message, "a三三三") {
t.Fatalf("文案应当仍回显开头几个字:%q", eobj.Message)
}
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
}
@@ -0,0 +1,279 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定记忆整理「面板调优」读写端点(`GET|PUT /api/v1/admin/memories/tidy/tuning`)
// 的判据:
// ① 读侧如实回显**盘上值**(键缺位 ⇒ 该键不出现)+ 取值规则 specs + `.env` 兜底值,
// 且不回显同文件里不受管的键;
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动(含「不重写文件」);合法值 ⇒ 采用且保留
// 同文件其它键;非法值 ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
// ③ 盘上文件解不开 ⇒ 读/写都 `409 tuning_corrupt` 且绝不覆盖;
// ④ `null` 是清除哨兵(删键 ⇒ 回到下一档兜底);
// ⑤ RBAC 走 writeAuth(owner|admin)、写落审计 `memory.tidy.tuning`;
// ⑥ 热生效**如实**:接线了 ⇒ hot=true 且真把值交给运行期;没接线/失败 ⇒ hot=false +
// hotError,绝不把「落盘了」说成「生效了」。
// tuneEnvT 是一个注入了数据目录(= 面板配置文件落点所在)的网关测试环境。
type tuneEnvT struct {
srv *Server
ts *httptest.Server
dir string
jwt *auth.JWTService
}
// newTuneEnv 构造环境。`cfg.MemoryTidy` 被显式填上,用来验证读侧下发的
// 「缺位时 `.env` 那一档」确实取自进程生效配置(而不是网关自己编的数)。
func newTuneEnv(t *testing.T, deps Deps) *tuneEnvT {
t.Helper()
dir := t.TempDir()
cfg := cfgDefault()
cfg.MemoryTidy = config.MemoryTidyConfig{IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
deps.DataDir = dir
srv, ts, jwt := buildEnv(t, cfg, deps)
return &tuneEnvT{srv: srv, ts: ts, dir: dir, jwt: jwt}
}
func (e *tuneEnvT) url() string {
return e.ts.URL + "/api/v1/admin/memories/tidy/tuning"
}
func (e *tuneEnvT) path() string {
return filepath.Join(e.dir, filepath.FromSlash(runtimeconfig.MemoryTidyConfigRelPath))
}
func (e *tuneEnvT) token(t *testing.T, role auth.Role) string {
t.Helper()
return signAny(t, e.jwt, "acct-"+string(role), role)
}
// seed 写入一份面板配置并返回写后的字节(后续逐字节比对用)。
func (e *tuneEnvT) seed(t *testing.T, body string) []byte {
t.Helper()
if err := os.MkdirAll(filepath.Dir(e.path()), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(e.path(), []byte(body), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
return e.bytes(t)
}
// bytes 读回盘上的原始字节:「非法值不落盘」的证据就是它与之前相等。
func (e *tuneEnvT) bytes(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(e.path())
if err != nil {
t.Fatalf("read tuning file: %v", err)
}
return raw
}
func (e *tuneEnvT) put(t *testing.T, token, raw string) (*http.Response, []byte) {
t.Helper()
return doRaw(t, http.MethodPut, e.url(), raw, token)
}
func (e *tuneEnvT) get(t *testing.T, token string) (*http.Response, []byte) {
t.Helper()
return doReq(t, http.MethodGet, e.url(), nil, token)
}
// view 解一次成功响应的 data。
func (e *tuneEnvT) view(t *testing.T, body []byte) AdminMemoryTidyTuningView {
t.Helper()
var view AdminMemoryTidyTuningView
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
t.Fatalf("decode view: %v (body=%s)", err, body)
}
return view
}
// assertErrorCode 断言响应是错误信封且 code 匹配。
func assertErrorCode(t *testing.T, body []byte, want string) {
t.Helper()
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error: %v (body=%s)", err, body)
}
if eobj.Code != want {
t.Fatalf("error code = %q, want %q (body=%s)", eobj.Code, want, body)
}
}
// diskDoc 解出盘上的文档(键 → 原样 JSON 值)。
func (e *tuneEnvT) diskDoc(t *testing.T) map[string]json.RawMessage {
t.Helper()
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(e.bytes(t), &doc); err != nil {
t.Fatalf("decode disk: %v", err)
}
return doc
}
// TestAdminMemoryTidyTuningRead_EchoesDiskValues 读侧:回显盘上 7 键(缺位不下发)、
// 下发取值规则与 `.env` 兜底值,且**不回显**同文件里不受管的键。
func TestAdminMemoryTidyTuningRead_EchoesDiskValues(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{
"intervalSec": 3600,
"quietStartHour": 23,
"quietEndHour": 7,
"ingestGate": false,
"t1PerWindow": 120
}`)
resp, body := env.get(t, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("read status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Path != runtimeconfig.MemoryTidyConfigRelPath || !view.Present {
t.Fatalf("view path/present = %q/%v", view.Path, view.Present)
}
if got := view.Values["intervalSec"]; got != float64(3600) {
t.Fatalf("values.intervalSec = %#v, want 3600", got)
}
if got := view.Values["ingestGate"]; got != false {
t.Fatalf("values.ingestGate = %#v, want false", got)
}
// 键缺位 ⇒ 不出现(「没配」与「配成 0」必须分得开)。
for _, absent := range []string{"quietIntervalSec", "autoApply", "tempTtlSec"} {
if _, ok := view.Values[absent]; ok {
t.Fatalf("values 不该出现 %s(盘上没这个键):%#v", absent, view.Values)
}
}
// 不受管的键(t1PerWindow)不进 values:这张卡改不了它,混进来会误导。
if _, ok := view.Values["t1PerWindow"]; ok {
t.Fatalf("values 不该出现不受管的 t1PerWindow:%#v", view.Values)
}
if len(view.Specs) != len(memoryTidyTuningManagedKeys()) {
t.Fatalf("specs = %d 条, want %d", len(view.Specs), len(memoryTidyTuningManagedKeys()))
}
if view.EnvValues["intervalSec"] != 86400 || view.EnvValues["quietStartHour"] != 23 {
t.Fatalf("envValues = %#v, want intervalSec=86400 / quietStartHour=23", view.EnvValues)
}
if view.Hot {
t.Fatal("只读响应里 hot 必须为 false(读不改变任何东西)")
}
}
// TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError 文件还没播种时:正常回 200、
// present=false、values 为空 —— 「还没配过」不是错误(与 runtimeconfig 的读取口径一致)。
func TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
resp, body := env.get(t, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Present || len(view.Values) != 0 {
t.Fatalf("view = %+v, want present=false / 无值", view)
}
}
// TestAdminMemoryTidyTuningRead_ViewerForbidden RBAC:viewer 403、匿名 401。
func TestAdminMemoryTidyTuningRead_ViewerForbidden(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600}`)
resp, body := env.get(t, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer read status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
resp, _ = env.get(t, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous read status = %d, want 401", resp.StatusCode)
}
}
// TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys 写侧「合法值 ⇒ 采用」:
// 7 键都能落盘,同文件里不受管的键**原样保留**(一次面板保存绝不抹掉别的旋钮)。
func TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"t1Judge": false, "t1PerWindow": 120}`)
// 用 admin 角色也走同一道门(writeAuth = owner|admin)。
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{
"intervalSec": 10800,
"quietIntervalSec": 3600,
"quietStartHour": 23,
"quietEndHour": 7,
"ingestGate": true,
"autoApply": true,
"tempTtlSec": 604800
}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("write status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
for key, want := range map[string]any{
"intervalSec": float64(10800), "quietIntervalSec": float64(3600),
"quietStartHour": float64(23), "quietEndHour": float64(7),
"ingestGate": true, "autoApply": true, "tempTtlSec": float64(604800),
} {
if got := view.Values[key]; got != want {
t.Fatalf("回显 values[%s] = %#v, want %#v", key, got, want)
}
}
doc := env.diskDoc(t)
if string(doc["intervalSec"]) != "10800" || string(doc["t1PerWindow"]) != "120" {
t.Fatalf("disk = %s", string(env.bytes(t)))
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", string(env.bytes(t)))
}
// 未接运行期 ⇒ 如实说「已落盘、重启后生效」。
if view.Hot || view.HotError == "" {
t.Fatalf("未接热生效时 hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
}
}
// TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched 三态之一:键**缺位** ⇒
// 不改动。空补丁连文件都不重写(mtime 都不动);只提交一个键时另一个键逐字节不动。
func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{"intervalSec": 86400, "quietStartHour": 23}`)
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(env.path(), past, past); err != nil {
t.Fatalf("chtimes: %v", err)
}
tok := env.token(t, auth.RoleOwner)
resp, body := env.put(t, tok, `{}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("空补丁改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
info, err := os.Stat(env.path())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().Equal(past) {
t.Fatalf("空补丁重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
}
if resp, body := env.put(t, tok, `{"quietStartHour": 22}`); resp.StatusCode != http.StatusOK {
t.Fatalf("partial patch status = %d (body=%s)", resp.StatusCode, body)
}
doc := env.diskDoc(t)
if string(doc["intervalSec"]) != "86400" || string(doc["quietStartHour"]) != "22" {
t.Fatalf("partial patch 结果 = %s", string(env.bytes(t)))
}
}
+10
View File
@@ -259,6 +259,16 @@ func (s *Server) registerAdminRoutes(
// 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。
mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject)))
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
// 记忆整理「面板调优」的读写(切片 MEMPANEL / M4;契约由塔登记):
// 读 .../memories/tidy/tuning 回显 `$DATA_DIR/config/memory-tidy.json` 里本卡片
// 管的 7 个旋钮 + 它们的取值规则(specs);写同一条路径是**三态补丁**
// (键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
// + 审计(memory.tidy.tuning)。两条都走 writeAuth(owner|admin)—— 与同族的
// GET /admin/memories、POST /admin/memories/{id}/revert 同档:它是私有记忆面的
// 运行期旋钮,不是给 viewer 的监控投影;也**不是** owner-only(写的是面板配置
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))