Files
Cyrene_v2/internal/services/backthink/heartbeat.go
T
AskaEth 236b202fda 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 处)已回给主会话排期
2026-10-05 22:38:17 +08:00

91 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}
}