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:
1 parent
d6ed9cfa2b
commit
236b202fda
13 files changed
+633
-54
No files matched your search
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoad_BackgroundThinkingDefaults 锁定后台思考心跳(design/12 §N A1)的 env
|
||||
// 契约默认值:普通心跳 900s、安静窗口内心跳 3600s。后者必须与设计稿一致(1 小时),
|
||||
// 否则「没配这个键」与「配了但解析失败」会走向不同的行为。
|
||||
func TestLoad_BackgroundThinkingDefaults(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
got := cfg.Background
|
||||
if !got.Enabled {
|
||||
t.Error("ENABLE_BACKGROUND_THINKING 默认应为 true")
|
||||
}
|
||||
if got.IntervalSec != 900 {
|
||||
t.Errorf("THINK_INTERVAL_SEC = %d, want 900", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec != 3600 {
|
||||
t.Errorf("THINK_QUIET_INTERVAL_SEC = %d, want 3600(design/12 §N A1)", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
|
||||
t.Errorf("安静时段 = %d-%d, want 23-7", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_BackgroundThinkingEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的生效
|
||||
// (键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。
|
||||
// `THINK_QUIET_INTERVAL_SEC=0` 是**合法值**(关闭降频,design/12 §N A3),因此它必须
|
||||
// 原样解析成 0,不能被默认值覆盖。
|
||||
func TestLoad_BackgroundThinkingEnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("THINK_INTERVAL_SEC", "1200")
|
||||
t.Setenv("THINK_QUIET_INTERVAL_SEC", "0")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Background.IntervalSec != 1200 {
|
||||
t.Fatalf("THINK_INTERVAL_SEC 未生效:%d", cfg.Background.IntervalSec)
|
||||
}
|
||||
if cfg.Background.QuietIntervalSec != 0 {
|
||||
t.Fatalf("THINK_QUIET_INTERVAL_SEC=0 必须原样解析成 0(关闭降频),得到 %d",
|
||||
cfg.Background.QuietIntervalSec)
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -216,18 +216,26 @@ type VoiceConfig struct {
|
||||
// single source shared by the backthink service and the channel bridge's
|
||||
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
|
||||
// disable the window. The Push*/Kind fields tune the proactive-push frequency of
|
||||
// design/11 §6 + design/12 §6 and are mapped into backthink.Config.
|
||||
// design/11 §6 + design/12 §6 and are mapped into backthink.Config. QuietIntervalSec
|
||||
// 是安静窗口内的心跳周期(design/12 §N A1):窗口只降低**周期性心跳**的频率,
|
||||
// 事件驱动通路完全不受影响(§N A2)。
|
||||
type BackgroundConfig struct {
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
|
||||
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
|
||||
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
|
||||
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
|
||||
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
|
||||
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
|
||||
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
|
||||
// QuietIntervalSec 是**安静窗口内**的心跳周期(秒),默认 3600(1 小时):
|
||||
// 当前时刻落在安静时段(QuietStartHour–QuietEndHour)内时,心跳改用该间隔降频,
|
||||
// 窗外仍用 IntervalSec(design/12 §N A1)。**≤0 = 关闭降频**(回落 IntervalSec,
|
||||
// 语义唯一真源 = internal/services/backthink/heartbeat.go 的 heartbeatInterval);
|
||||
// 它不是「半夜完全不思考」的开关,见 design/12 §N A3。
|
||||
QuietIntervalSec int `env:"THINK_QUIET_INTERVAL_SEC" default:"3600"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
|
||||
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
|
||||
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
|
||||
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
|
||||
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
|
||||
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
|
||||
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
|
||||
}
|
||||
|
||||
// MemoryTidyConfig 是记忆整理(design/23-记忆整理与去重)的开关与配额旋钮。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user