feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600

主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
This commit is contained in:
AskaEth committed 2026-10-05 22:38:17 +08:00
1 parent d6ed9cfa2b
commit 236b202fda
13 files changed
+633 -54

No files matched your search

+6 -28
View File
@@ -6,7 +6,10 @@
// - 周期性心跳(config.Background.IntervalSec),请求注入的运行时
// 反思,并允许调用工具(与主人对话**同一套**工具面:同一个
// loop / dispatcher / 能力注册表,见 thinker.go 的 LoopThinker),
// 但默认不得发出 chat 风格的消息。
// 但默认不得发出 chat 风格的消息。心跳周期**按当前时刻选**:
// 落在安静窗口内降频到 config.Background.QuietIntervalSec,
// 窗外回落 IntervalSec(design/12 §N A1/A3,见 heartbeat.go)。
// 降频**只作用于周期性心跳**,事件驱动通路照旧立刻触发(§N A2)。
// - 进程内总线上的异步事件(iot.device_update、
// reminder.trigger、system.event),它们唤醒一轮思考。
//
@@ -211,6 +214,8 @@ func (b *Backthink) Start(ctx context.Context) {
b.log.Info("backthink started",
"interval", b.cfg.Interval.String(),
"quiet_interval", b.cfg.QuietInterval.String(),
"quiet_interval_enabled", b.quietIntervalEnabled(),
"quiet_hours", quietHoursLabel(b.cfg.QuietStartHour, b.cfg.QuietEndHour),
"event_triggers", b.deps.Bus != nil,
"session_id", b.sessionID)
@@ -324,33 +329,6 @@ func (b *Backthink) shouldThink(tr Trigger) bool {
return true
}
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
// 启动时立即思考一次——产出一句拟人化的启动问候——
// 随后稳定为每 tick 一轮思考;ctx 被取消时
// 退出。可接受的副作用是每次进程重启都会唤醒
// 一轮即时思考。
func (b *Backthink) tickLoop(ctx context.Context) {
defer b.wg.Done()
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
// 启动记录。
select {
case <-ctx.Done():
return
default:
}
b.think(ctx, startupTrigger())
ticker := time.NewTicker(b.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
b.think(ctx, heartbeatTrigger())
}
}
}
// think 跑一轮无人值守的思考:调用运行时、读取用户
// 状态、判断是否打扰、值得时推送并持久化记录。
// 只要注入的 Thinker、StateProvider 与 Pusher
+16
View File
@@ -60,6 +60,15 @@ type Config struct {
// IntervalSec 是心跳周期秒数,映射自
// config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。
IntervalSec int
// QuietInterval 是**安静窗口内**的心跳周期(design/12 §N A1)。
// 为零时由 QuietIntervalSec 推导;**≤0 表示关闭降频**(回落 Interval),
// 因此这里刻意不做默认填充——生产默认 3600 由 env 标签给出
// (config.BackgroundConfig.QuietIntervalSec)。
QuietInterval time.Duration
// QuietIntervalSec 是安静窗口内的心跳周期秒数,映射自
// config.BackgroundConfig.QuietIntervalSec。设置了 QuietInterval 时忽略。
// ≤0 = 关闭降频(回落 Interval,design/12 §N A3)。
QuietIntervalSec int
// QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。
// 两个相等的非零钟点会禁用该窗口。
QuietStartHour int
@@ -89,6 +98,7 @@ type Config struct {
func ConfigFromBackground(bg config.BackgroundConfig) Config {
return Config{
IntervalSec: bg.IntervalSec,
QuietIntervalSec: bg.QuietIntervalSec,
QuietStartHour: bg.QuietStartHour,
QuietEndHour: bg.QuietEndHour,
PushLimit: bg.PushLimit,
@@ -107,6 +117,12 @@ func normalize(cfg Config) Config {
if cfg.Interval <= 0 {
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
}
// QuietInterval 不从零值套默认:≤0 是本设计里「关闭降频」的**语义**
// (design/12 §N A3),把 0 改写成 3600 会让用户设的 0 静默失效。
// 生产默认 3600 由 config 层的 env 标签供值。
if cfg.QuietInterval <= 0 {
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second
}
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
cfg.QuietStartHour = defaultQuietStartHour
cfg.QuietEndHour = defaultQuietEndHour
+90
View File
@@ -0,0 +1,90 @@
package backthink
import (
"context"
"time"
)
// 本文件承载心跳节拍(design/12 §N A):安静窗口内降频、窗口外恢复。
//
// 两条不变量(照 design/12 §N 写死,改它必须先改设计):
// - **窗口只降低周期性心跳的频率**;事件驱动通路(onEvent → think)
// 完全不经这里,因此它照旧立刻触发、照样穿透安静时段(§N A2)。
// - **寂静 ≠ 停摆**:`QuietInterval ≤ 0` 只表示「关闭降频」(回落普通间隔),
// 绝不表示「半夜完全不思考」(§N A3)。
// quietHourMin 与 quietHourMax 是安静时段钟点的合法取值域(0..23)。
// 越界即视为窗口非法 ⇒ 不降频(design/12 §N A3)。
const (
quietHourMin = 0
quietHourMax = 23
)
// quietIntervalEnabled 报告是否配置了降频间隔。`≤0` = 关闭降频:
// 这是「回落普通间隔」而不是「半夜不思考」,故这里只做一次符号判定。
func (b *Backthink) quietIntervalEnabled() bool {
return b.cfg.QuietInterval > 0
}
// quietWindowDisabled 报告安静窗口是否**不可用**(两端相等非 0,或钟点非法)。
// 口径与既有三处一致之处在于「相等非 0 = 关闭」;「非法钟点」是本服务按
// design/12 §N A3 补的:越界钟点在既有 inQuietHours 里会算出荒谬窗口
// (如 5..25 恒真),把降频错误地全时段打开,因此这里先判非法 ⇒ 不降频。
func (b *Backthink) quietWindowDisabled() bool {
start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour
if !validQuietHour(start) || !validQuietHour(end) {
return true
}
return start == end
}
// validQuietHour 报告钟点是否落在合法取值域 0..23(含两端)。
func validQuietHour(hour int) bool {
return hour >= quietHourMin && hour <= quietHourMax
}
// heartbeatInterval 按**当前时刻**选心跳间隔:落在安静窗口内用
// QuietInterval,否则用 Interval(design/12 §N A1)。
//
// 它是纯函数式的(只读 cfg 与入参),因此 tickLoop 可以在**每一拍之前**
// 重算它——跨过窗口边界后下一拍自动切换,而不是启动时定死一次(§N A3)。
// 安静判定直接复用既有的 inQuietHours,跨午夜口径与另外三处逐字一致。
func (b *Backthink) heartbeatInterval(at time.Time) time.Duration {
if !b.quietIntervalEnabled() || b.quietWindowDisabled() {
return b.cfg.Interval
}
if b.inQuietHours(at) {
return b.cfg.QuietInterval
}
return b.cfg.Interval
}
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
// 启动时立即思考一次——产出一句拟人化的启动问候——
// 随后稳定为每拍一轮思考;ctx 被取消时退出。可接受的副作用是每次进程重启都会唤醒
// 一轮即时思考。
//
// 节拍在**每拍前**用 heartbeatInterval(b.now()) 重算(design/12 §N A3):
// 因此心跳在安静窗口内降频、出窗口后自动恢复。用 time.Timer 而非
// 固定 Ticker 正是为了这个「每拍换间隔」的能力。
func (b *Backthink) tickLoop(ctx context.Context) {
defer b.wg.Done()
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
// 启动记录。
select {
case <-ctx.Done():
return
default:
}
b.think(ctx, startupTrigger())
for {
timer := time.NewTimer(b.heartbeatInterval(b.now()))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
b.think(ctx, heartbeatTrigger())
}
}
}
@@ -0,0 +1,287 @@
package backthink
import (
"bytes"
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件钉住 design/12 §N「静默时段降频」:心跳在安静窗口内降频、窗外回落,
// 且降频**只作用于周期性心跳**——事件驱动通路照旧立刻触发(§N A2)。
//
// 三条边界必须同时成立,缺一条就是「暗坑」:
// 1. 每拍**重算**间隔(不是启动时定死)⇒ 跨窗口边界真的切换;
// 2. `QuietInterval ≤ 0` = 关闭降频(回落普通间隔),**不是**「半夜不思考」;
// 3. 安静判定与既有三处 `inQuietHours` 同源(不另写一份越走越偏的口径)。
// fakeClock 是可注入的墙钟:测试在运行中挪动它,以驱动「跨窗口边界」。
type fakeClock struct {
mu sync.Mutex
at time.Time
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *fakeClock) set(at time.Time) {
c.mu.Lock()
c.at = at
c.mu.Unlock()
}
// atHour 造一个本地钟点为 h:m 的时刻(UTC 承载,Hour() 即 h)。
func atHour(h, m int) time.Time {
return time.Date(2026, 10, 5, h, m, 0, 0, time.UTC)
}
// intervalFor 在给定配置上取 at 时刻的心跳间隔(经 normalize 后的真实路径)。
func intervalFor(cfg Config, at time.Time) time.Duration {
b := &Backthink{cfg: normalize(cfg)}
return b.heartbeatInterval(at)
}
// TestHeartbeatInterval_SelectsByWindow 覆盖 design/12 §N A6 的 ①~④:
// 窗口内用 quiet 间隔、窗口外用普通间隔、`≤0` 回落普通、窗口关闭不降频。
func TestHeartbeatInterval_SelectsByWindow(t *testing.T) {
t.Parallel()
const normal = 15 * time.Minute
const quiet = time.Hour
cases := []struct {
name string
cfg Config
at time.Time
want time.Duration
}{
{
name: "①窗口内降频(跨午夜 23:00-07:00 的 23:30)",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(23, 30),
want: quiet,
},
{
name: "②窗口外回落普通间隔(14:00)",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(14, 0),
want: normal,
},
{
name: "窗口起点(23:00)算在窗口内",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(23, 0),
want: quiet,
},
{
name: "窗口终点(07:00)算在窗口外",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(7, 0),
want: normal,
},
{
name: "白天窗口(01:00-05:00)内的 02:00 降频",
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 1, QuietEndHour: 5},
at: atHour(2, 0),
want: quiet,
},
{
name: "③QuietInterval=0 ⇒ 关闭降频(窗口内也回落)",
cfg: Config{Interval: normal, QuietInterval: 0},
at: atHour(23, 30),
want: normal,
},
{
name: "③QuietInterval 为负 ⇒ 同样关闭降频",
cfg: Config{Interval: normal, QuietInterval: -time.Minute},
at: atHour(23, 30),
want: normal,
},
{
name: "④窗口关闭(23/23)不降频",
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 23, QuietEndHour: 23},
at: atHour(23, 30),
want: normal,
},
{
name: "④钟点非法(25/07)不降频(否则可能全时段降频)",
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 25, QuietEndHour: 7},
at: atHour(0, 30),
want: normal,
},
{
name: "0/0 视为未填 ⇒ 回落默认窗口 23:00-07:00(窗口内降频)",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(23, 30),
want: quiet,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := intervalFor(tc.cfg, tc.at); got != tc.want {
t.Fatalf("heartbeatInterval(%s) = %v, want %v", tc.at.Format("15:04"), got, tc.want)
}
})
}
}
// TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours 钉住降频用的是**同一套**
// 安静窗口口径(design/12 §N A3:与既有三处逐字一致):静默间隔开启且窗口合法时,
// quiet 档位**当且仅当** inQuietHours。另写一份判定(如写成 `h > start && h <= end`)
// 会在跨午夜窗口的边界小时上立刻暴露。
func TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours(t *testing.T) {
t.Parallel()
const normal = 900 * time.Second
const quiet = 3600 * time.Second
windows := []struct{ start, end int }{{23, 7}, {1, 5}, {0, 7}, {22, 3}}
for _, w := range windows {
b := &Backthink{cfg: normalize(Config{
Interval: normal, QuietInterval: quiet, QuietStartHour: w.start, QuietEndHour: w.end,
})}
for hour := 0; hour < 24; hour++ {
at := atHour(hour, 30)
inWindow := b.inQuietHours(at)
want := normal
if inWindow {
want = quiet
}
if got := b.heartbeatInterval(at); got != want {
t.Fatalf("窗口 %02d:00-%02d:00 的 %02d:30:inQuietHours=%v 但间隔=%v(want %v)",
w.start, w.end, hour, inWindow, got, want)
}
}
}
}
// TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary 钉住「每拍重算」
// (design/12 §N A3):注入时钟从窗口内走到窗口外,心跳必须**加速**到普通间隔。
// 若间隔只在启动时算一次,第一拍之后仍是 quiet 间隔 ⇒ 拍数远达不到阈值 ⇒ 红。
func TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary(t *testing.T) {
t.Parallel()
const normal = 50 * time.Millisecond
const quiet = 500 * time.Millisecond
clock := &fakeClock{at: atHour(23, 30)} // 起点在窗口内
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{
Thinker: thinker,
Now: clock.now,
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮
clock.set(atHour(12, 0)) // 走出窗口
// 阈值取 8 拍:只在启动时算一次(quiet=500ms)的话,3 秒内最多 6 拍;
// 每拍重算则 500ms 首拍 + 之后每 50ms 一拍 ⇒ 约 900ms 即达标。
eventually(t, 3*time.Second, func() bool { return thinker.count()-1 >= 8 })
}
// TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow 是上面那条的反向:时钟从窗口外
// 走进窗口内,心跳应放慢到 quiet 间隔(20ms ⇒ 600ms)。
func TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow(t *testing.T) {
t.Parallel()
const normal = 20 * time.Millisecond
const quiet = 600 * time.Millisecond
clock := &fakeClock{at: atHour(12, 0)} // 起点在窗口外
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{
Thinker: thinker,
Now: clock.now,
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 3 }) // 快节奏已建立
clock.set(atHour(23, 30)) // 走进窗口
base := thinker.count()
time.Sleep(300 * time.Millisecond)
// 已武装的那一拍最多再多跑 1-2 次,之后应被 quiet 间隔挡住;若降频没生效,
// 20ms 的节奏会在 300ms 里再跑约 15 拍。
if got := thinker.count() - base; got > 3 {
t.Fatalf("进入安静窗口后 300ms 内又跑了 %d 拍,quiet 间隔未生效", got)
}
}
// TestBackthink_EventTrigger_NotSlowedByQuietInterval 是 design/12 §N A2 / A6-⑤:
// 安静窗口只降低**周期性心跳**的频率;事件驱动通路照旧**立刻**触发,不必等下一拍。
// 心跳间隔被刻意设成小时级,事件仍须在秒级内跑完一轮思考。
func TestBackthink_EventTrigger_NotSlowedByQuietInterval(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
bs := bus.New()
b, _ := newBackthink(t, Config{Interval: time.Hour, QuietInterval: 4 * time.Hour}, Deps{
Bus: bs,
Thinker: thinker,
Now: func() time.Time { return nightAt() }, // 当前在安静窗口内
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮
base := thinker.count()
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "reminder due"); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, time.Second, func() bool { return thinker.count() > base })
}
// TestBackthink_StartupLog_ReportsQuietInterval 是 design/12 §N A6-⑥:启动日志必须
// **同时**打印普通间隔与安静间隔(并标明降频是否启用),否则主人无法从日志确认
// 降频到底有没有生效。
func TestBackthink_StartupLog_ReportsQuietInterval(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: 15 * time.Minute, QuietInterval: time.Hour}, Deps{
Thinker: thinker,
Log: slog.New(slog.NewTextHandler(&buf, nil)),
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
out := buf.String()
for _, want := range []string{"interval=15m0s", "quiet_interval=1h0m0s", "quiet_interval_enabled=true"} {
if !strings.Contains(out, want) {
t.Fatalf("启动日志缺少 %q:\n%s", want, out)
}
}
}
// TestConfigFromBackground_QuietInterval 钉住 env → backthink 的映射与 ≤0 语义
// (design/12 §N A1/A3):3600 秒 ⇒ 1 小时的降频间隔;`≤0` 必须原样保留成
// 「关闭降频」——normalize **不得**把它改写成默认值,否则主人设的 0 会静默失效
// (那正是 §N A3 要禁的暗坑)。
func TestConfigFromBackground_QuietInterval(t *testing.T) {
t.Parallel()
on := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: 3600}))}
if on.cfg.QuietInterval != time.Hour {
t.Fatalf("QuietIntervalSec=3600 ⇒ QuietInterval=%v, want 1h", on.cfg.QuietInterval)
}
if !on.quietIntervalEnabled() {
t.Fatal("QuietIntervalSec=3600 必须视为降频已启用")
}
for _, sec := range []int{0, -1} {
off := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: sec}))}
if off.quietIntervalEnabled() {
t.Fatalf("QuietIntervalSec=%d 必须被视为「关闭降频」", sec)
}
if got := off.heartbeatInterval(atHour(23, 30)); got != off.cfg.Interval {
t.Fatalf("QuietIntervalSec=%d 时窗口内心跳应为普通间隔 %v,得到 %v", sec, off.cfg.Interval, got)
}
}
}
@@ -8,8 +8,8 @@ import (
)
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键)。三条边界(AGENTS §10 与
// 主人红线):
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
// (AGENTS §10 与主人红线):
//
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
// 一份清单):事实表逐条与模板键名对齐,由
@@ -20,7 +20,7 @@ import (
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 9 个非敏感调参键,响应里连
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 12 个非敏感调参键,响应里连
// 凭据类键的「是否已配置」都不出现。
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
//
@@ -77,7 +77,7 @@ type configFactSpec struct {
}
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
// 后安静时段)。键名必须与 `.env.example` 逐字一致(测试钉住)。
// 后安静时段与后台思考心跳)。键名必须与 `.env.example` 逐字一致(测试钉住)。
func configFactSpecs() []configFactSpec {
return []configFactSpec{
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
@@ -101,6 +101,17 @@ func configFactSpecs() []configFactSpec {
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
}},
// 后台思考心跳(design/12 §N A4):安静窗口只降低周期性心跳的频率,
// 而 env 是**启动期**读的 ⇒ 面板照实列出「生效值 + 声明默认」并写明改需重启。
{"ENABLE_BACKGROUND_THINKING", func(c *config.Config) string {
return strconv.FormatBool(c.Background.Enabled)
}},
{"THINK_INTERVAL_SEC", func(c *config.Config) string {
return strconv.Itoa(c.Background.IntervalSec)
}},
{"THINK_QUIET_INTERVAL_SEC", func(c *config.Config) string {
return strconv.Itoa(c.Background.QuietIntervalSec)
}},
{"QUIET_START_HOUR", func(c *config.Config) string {
return strconv.Itoa(c.Background.QuietStartHour)
}},
@@ -117,7 +128,7 @@ func formatRatio(v float64) string {
}
// buildConfigFacts 组装配置事实面。它是纯函数(不读文件、不读 env),因此可
// 直接单测;cfg 为 nil 时回退到一个零值配置(此时 9 条事实全部照实报零值 +
// 直接单测;cfg 为 nil 时回退到一个零值配置(此时 12 条事实全部照实报零值 +
// 声明默认值,面板会看到 isDefault=false 的「已改」而不是一个编出来的值)。
func buildConfigFacts(cfg *config.Config) AdminConfigFacts {
if cfg == nil {
@@ -106,6 +106,35 @@ func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
}
}
// TestAdminConfigFacts_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条
// 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` /
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 12。键名与
// `.env.example` 逐字一致那一半由 TestAdminConfigFacts_KeysMatchEnvExample 钉。
func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
t.Parallel()
cfg := cfgDefault()
cfg.Background.Enabled = false
cfg.Background.IntervalSec = 7200
cfg.Background.QuietIntervalSec = 0 // ≤0 = 关闭降频;事实面必须如实报 0
_, _, facts := getConfigFacts(t, cfg, "")
if len(facts.Facts) != 12 {
t.Fatalf("facts = %d, want 12(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
}
for key, want := range map[string]string{
"ENABLE_BACKGROUND_THINKING": "false",
"THINK_INTERVAL_SEC": "7200",
"THINK_QUIET_INTERVAL_SEC": "0",
} {
got := factByKey(t, facts.Facts, key)
if got.Value != want {
t.Fatalf("%s value = %q, want %q(进程生效值)", key, got.Value, want)
}
if got.IsDefault {
t.Fatalf("%s 被改过,isDefault 必须为 false:%+v", key, got)
}
}
}
// TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag 是判据 ②:每条事实都带
// **声明默认值**(default 标签,非空),且当生效值等于它时 isDefault=true ——
// 面板因此能如实显示「默认」,而不是把默认值当成「用户配过的值」。
@@ -119,6 +148,9 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
"MEMORY_TIDY_SCAN_LIMIT": "5000",
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
"ENABLE_BACKGROUND_THINKING": "true",
"THINK_INTERVAL_SEC": "900",
"THINK_QUIET_INTERVAL_SEC": "3600",
"QUIET_START_HOUR": "23",
"QUIET_END_HOUR": "7",
}
@@ -127,7 +159,10 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
},
Background: config.BackgroundConfig{QuietStartHour: 23, QuietEndHour: 7},
Background: config.BackgroundConfig{
Enabled: true, IntervalSec: 900, QuietIntervalSec: 3600,
QuietStartHour: 23, QuietEndHour: 7,
},
}
status, _, facts := getConfigFacts(t, defaultCfg, "")
if status != http.StatusOK {
@@ -274,7 +309,7 @@ func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
}
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
// `.env.example` —— 9 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// `.env.example` —— 12 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
t.Parallel()