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