塔裁定:清扫不许挂在注入缝上 —— 包一层 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(摘掉那一行 ⇒ 用例红,已实测)。
151 lines
9.2 KiB
Go
151 lines
9.2 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
)
|
||
|
||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||
//
|
||
// 六件事只在这里定:
|
||
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
|
||
// 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
|
||
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
|
||
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
|
||
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
|
||
// 变成「无限制」,也不能让整理频率变成忙轮询);
|
||
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
|
||
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
|
||
// 使「面板优先」从第一次启动起就成立,而不是靠 `.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 的图源白名单(默认关:整理是系统任务,
|
||
// 不是主人的会话)。
|
||
func (a *app) wireMemoryTidy() error {
|
||
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||
a.applyMemoryTidyTuning(&cfg)
|
||
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
|
||
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
|
||
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
|
||
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
|
||
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
|
||
a.seedMemoryTidyPanel(cfg)
|
||
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||
Store: a.memStore,
|
||
Embedder: a.embedder,
|
||
Audit: a.auditMemoryTidy,
|
||
Log: logger.WithSource(a.log, "memory/tidy"),
|
||
})
|
||
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
|
||
}
|
||
|
||
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
|
||
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
|
||
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
|
||
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
|
||
//
|
||
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)**外加**临时
|
||
// 记忆 TTL 一键(tempTtlSec,切片 M2 加进来的第五个 review 键)在 `.env` 里
|
||
// **都没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
|
||
// wireMemoryReview 的四个 helper:memoryIngestGateOff / memoryT1JudgeOff /
|
||
// memoryT1RateLimit / memoryTempTTL),而播种只发生在**面板文件不存在**时 —— 那正是
|
||
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
|
||
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十一键里,
|
||
// 因此不必为它去读一次 env。
|
||
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
|
||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||
log := logger.WithSource(a.log, "memory/tidy")
|
||
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
|
||
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
|
||
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
|
||
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
|
||
}
|
||
}
|
||
|
||
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
|
||
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
|
||
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
|
||
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
|
||
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
|
||
// `.env` > 代码默认),坏值回落 + WARN。
|
||
//
|
||
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
|
||
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
|
||
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
|
||
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
|
||
tuning, path, err := a.readMemoryTidyTuning()
|
||
log := logger.WithSource(a.log, "memory/tidy")
|
||
if err != nil {
|
||
log.Warn(
|
||
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
|
||
"path", path, "error", err)
|
||
} else {
|
||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||
if tuning.AutoApplyPerRound > 0 {
|
||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||
}
|
||
}
|
||
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
|
||
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
|
||
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
|
||
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
|
||
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
|
||
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
|
||
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
|
||
log.Info(
|
||
"memory tidy tuning applied (panel > .env > code defaults)",
|
||
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
|
||
"auto_apply_per_round", cfg.AutoApplyPerRound,
|
||
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
|
||
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
|
||
}
|
||
|
||
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
|
||
// 它把「落点 + 读取 + 路径回报」收在一处,供整理自动档与**同步入库闸**两个
|
||
// 消费者共用 —— 后者(wireMemoryReview)不属于整理服务,但它的开关就在同一个
|
||
// 文件里(零新 env 键,design/13 §2.1)。错误由调用方记 WARN 并回落代码默认值。
|
||
func (a *app) readMemoryTidyTuning() (runtimeconfig.MemoryTidyTuning, string, error) {
|
||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||
if err != nil {
|
||
return runtimeconfig.MemoryTidyTuning{}, path, err
|
||
}
|
||
return tuning, path, nil
|
||
}
|
||
|
||
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
||
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
||
func (a *app) auditMemoryTidy(who, what, result string) {
|
||
if a.gw != nil {
|
||
a.gw.AuditEvent(who, what, result)
|
||
return
|
||
}
|
||
logger.WithSource(a.log, "memory/tidy").Info(
|
||
"memory tidy audit dropped: gateway not ready", "who", who, "what", what, "result", result)
|
||
}
|