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

+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())
}
}
}