feat(backthink): 自排 C1-2 本体 + TriggerUnknown 修 fail-open(V1)

- selfwake.go:WakeNow + 待跑槽容量 1(先探槽合并不过闸、槽空才过闸、同锁)+ 闸 4 轮/小时
  ($DATA_DIR/config/backthink.json,零新 env 键)+ 审计四条 what + 启动日志两键 + 组合根接线
- G-A(主人已批):TriggerUnknown 作零值、既有两值编号后移(语义不动),未知触发走**最紧分支**
- 5 个变异自检(拆闸/≤0 未回落/过闸挪到探槽前=G1 失效形态/槽无界/零值挪走)
⚠️ 服务侧已就绪但**她仍调不到**(自排工具面属 C1-3)⇒ 不得写「C 已完成」
This commit is contained in:
AskaEth committed 2026-10-06 20:25:04 +08:00
1 parent 301f8b4276
commit ed5005307a
12 files changed
+1193 -8

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
package backthink
import (
"context"
"errors"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件是**自我唤醒(自排一次思考)的本体**(design/12 §N C,切片 C1-2)。
//
// 形状(C.0):她自己发起一次唤醒请求 → 先探**自排专用、容量 1** 的待跑槽
// (满 ⇒ 合并,**不过闸、不扣额度**)→ **槽空才**过窗口闸 → 单 worker 跑一轮
// TriggerSelfWake 思考。
//
// 三条不许破坏的约束(各有探针,见 C.8):
// 1. **顺序**:探槽 → 过闸 → 入槽,三步在**同一把锁**内(C.8 判据 13)。颠倒成
// 「先过闸、再探槽」,被合并的请求也会白扣一次额度,与「合并不消耗预算」
// 自相矛盾(session.RateLimiter.Allow() **成功即消耗**)。
// 2. **闸前 normalize**(C.8 判据 14):进闸的永远是已回落默认的值,因为
// RateLimiter 在 limit<=0 时 Allow() 恒 true 且不记录 ⇒ 未回落的值会把
// 「≤0 回落默认」静默变成「无上限」。
// 3. **容量 1 只限自排轮之间**:至多 1 轮自排在跑 + 至多 1 轮自排在排队;
// 心跳轮与事件轮照旧可与自排轮并发(C1 **不**引入全局回合互斥,G2)。
//
// 自排只保证「她能被叫醒思考」;要不要发出去仍由既有的 decide 级联 + PushGate 决定
// (C.4/C.5/C.7),本文件不碰推送策略。
// 自排的哨兵错误。用 errors.Is 判定;它们被直接返回或用 %w 包装。
var (
// ErrSelfWakeUnavailable 在服务未 Start 或已 Stop 时返回 —— **绝不假装排上**
// (红线:不伪造成功)。
ErrSelfWakeUnavailable = errors.New("backthink: self wake unavailable")
// ErrSelfWakeNotOwner 在非主人环境下请求自排时返回(工具面是第一道闸,
// 这里是第二道)。design/12 §C.3 / §C.5 的 denied。
ErrSelfWakeNotOwner = errors.New("backthink: self wake requires owner environment")
)
// SelfWakeReceipt 是一次自排请求的结果。字段与工具结果一一对应(design/12 §C.1/C.3),
// 因此「被上限拒绝」是她**看得见**的合法业务结果,而不是一个 error。
type SelfWakeReceipt struct {
// Accepted 报告是否真的排出一轮(合并 = true;超限 = false)。
Accepted bool
// Coalesced 报告这次请求与在队的那一轮合并了(不额外产生一轮、不花钱)。
Coalesced bool
// Reason 是拒绝/说明文案,可直接显示;成功时为空。
Reason string
// Used 是**本窗口已用**额度,在消耗之后取值(合并/拒绝路径取当前已用值,
// 不因这次请求 +1)。
Used int
// Limit 与 WindowSec 是本窗口的口径(已 normalize 的值),供面板与工具面显示。
Limit int
WindowSec int
}
// Auditor 是自排审计的窄接口:三字段与网关的 AuditEvent 同形(gateway/audit.go),
// 因此 *gateway.Server 直接满足它。审计出口是**晚绑定**的(见 sinks.go 的
// SetAuditSink):网关比 backthink 晚组装。
type Auditor interface {
AuditEvent(who, what, result string)
}
// 自排审计行的 who 与四个动作。what 用点号小写的稳定标签(与 tool.reject.not_allowed
// 同族);result 是可直接显示给主人的中文。
const (
selfWakeAuditWho = "backthink"
whatSelfWakeQueued = "backthink.self_wake.queued"
whatSelfWakeCoalesced = "backthink.self_wake.coalesced"
whatSelfWakeCapped = "backthink.self_wake.capped"
whatSelfWakeDenied = "backthink.self_wake.denied"
)
// WakeNow 是她自己排起一轮思考的入口(design/12 §N C.1)。它只负责**排**:
// 探槽、过闸、入槽,或如实返回合并/拒绝的收据;真正的思考由单 worker 跑。
//
// 步骤(顺序不许颠倒,C.1 步骤 1~4):
// 1. 未 Start / 已 Stop ⇒ ErrSelfWakeUnavailable(不假装排上);
// 2. 非主人环境 ⇒ ErrSelfWakeNotOwner + denied 审计(**不做事**);
// 3. 先探容量 1 的待跑槽:满 ⇒ 合并(Accepted=true、Coalesced=true),
// **不过窗口闸、不扣额度**,在队的那一轮仍用先到的理由;
// 4. 槽空 ⇒ 过闸:拒 ⇒ Accepted=false + capped 审计 + 带 source 的 WARN
// (Allow() 返回 false 时本就未消耗 ⇒ 拒绝不占额度、不产生轮次);
// 过 ⇒ 入槽(额度此刻已消耗)+ queued 审计。
//
// ctx 只用于身份环境与日志;待跑的那一轮不持有它(不引入延时/欠账语义,
// 也不让一次请求把调用方的 ctx 生命周期绑进 worker)。
func (b *Backthink) WakeNow(ctx context.Context, req SelfWakeRequest) (SelfWakeReceipt, error) {
gate, running := b.wakeGate()
if gate == nil || !running {
return SelfWakeReceipt{}, ErrSelfWakeUnavailable
}
if ctx == nil {
ctx = context.Background()
}
// 第二道身份闸(第一道在工具面,C.3):非主人环境一律拒绝,并留一条 denied
// 审计 —— 拒绝要看得见,不许静默。loop.SessionEnvFrom 对未挂载 env 的 ctx 返回
// 主人默认值(loop 的既有向后兼容口径),因此这条判定与工具面读的是同一个环境。
if env := loop.SessionEnvFrom(ctx); env.ActorEnv != restrict.ActorOwner {
b.auditEvent(whatSelfWakeDenied, fmt.Sprintf("非主人环境,拒绝自排(actor=%s)", env.ActorEnv))
return SelfWakeReceipt{}, ErrSelfWakeNotOwner
}
receipt, queued := gate.offer(req)
b.logSelfWake(req, receipt, queued)
return receipt, nil
}
// logSelfWake 把一次自排请求落到审计与日志上(四条 what 里的三条;denied 在
// WakeNow 的身份闸处记)。审计失败不改变结果:审计是留痕,不是门。
func (b *Backthink) logSelfWake(req SelfWakeRequest, receipt SelfWakeReceipt, queued bool) {
switch {
case receipt.Coalesced:
b.auditEvent(whatSelfWakeCoalesced,
fmt.Sprintf("已合并到排队中的那一轮(不额外占额度),理由:%s", req.Reason))
b.log.Info("backthink self wake coalesced",
"reason", req.Reason, "declared", req.Declared.String(), "used", receipt.Used, "limit", receipt.Limit)
case receipt.Accepted:
b.auditEvent(whatSelfWakeQueued, fmt.Sprintf("已排入一轮自主思考,理由:%s", req.Reason))
b.log.Info("backthink self wake queued",
"reason", req.Reason, "declared", req.Declared.String(), "used", receipt.Used, "limit", receipt.Limit)
case queued:
// 不可达:queued 为真时 Accepted 必为真。留着是为了让「排上了却没记审计」
// 在下一次改动里立刻显形,而不是静默少一条。
b.log.Warn("backthink self wake queued without audit", "reason", req.Reason)
default:
b.auditEvent(whatSelfWakeCapped, receipt.Reason)
b.log.Warn("backthink self wake capped",
"reason", req.Reason, "used", receipt.Used, "limit", receipt.Limit,
"window", b.cfg.SelfWakeWindow.String())
}
}
// auditEvent 把一条审计写进晚绑定的出口(未接线时静默跳过:审计缺失不阻塞业务)。
func (b *Backthink) auditEvent(what, result string) {
if a := b.currentAuditor(); a != nil {
a.AuditEvent(selfWakeAuditWho, what, result)
}
}
// wakeGate 在生命周期锁下取出自排闸并报告服务是否在跑。未 Start / 已 Stop 时
// running 为 false ⇒ 调用方返回 ErrSelfWakeUnavailable(不假装排上)。
func (b *Backthink) wakeGate() (*selfWakeGate, bool) {
b.mu.Lock()
defer b.mu.Unlock()
return b.selfWake, b.running
}
// selfWakeGate 是自排的**成本闸 + 待跑槽**(design/12 §N C.1/C.5)。它把三件事
// 锁在一起:容量 1 的待跑槽、每窗口上限的滑窗,以及「探槽 → 过闸 → 入槽」的强制顺序。
//
// 锁序:本闸的互斥量只包住自己的状态;RateLimiter 自带内部锁且绝不回调本闸,
// 因此不存在反向加锁路径(C.1 步骤 4 提到的「无锁序问题」)。
type selfWakeGate struct {
mu sync.Mutex
// limiter 是每窗口上限的滑窗(复用既有件 ⇒ 零新依赖、零新机制)。
// 它拿到手的必须是 normalize 之后的值:limit<=0 时它恒放行且不记录。
limiter *session.RateLimiter
limit int
window time.Duration
// granted 是「本窗口已用」的镜像,**只为上报**(收据的 Used 与审计文案)。
// 判定始终由 limiter 做;两者用同一个时间源与同一个窗口,因此不会各说各话。
granted []time.Time
// pending 是容量 1 的待跑槽。非 nil ⇒ 满了,后来的请求合并进它。
pending *SelfWakeRequest
// ready 只用来唤醒 worker(容量 1,非阻塞发送)。它不承载状态:
// 状态在 pending 上,因此不会出现「收到通知但槽是空的」以外的失配。
ready chan struct{}
}
// newSelfWakeGate 构建闸。limit/window 必须是**已 normalize** 的值
// (≤0 会得到恒放行的滑窗,见 cfg.go 的 normalize 注释)。
func newSelfWakeGate(limit int, window time.Duration) *selfWakeGate {
return &selfWakeGate{
limiter: session.NewRateLimiter(limit, window),
limit: limit,
window: window,
ready: make(chan struct{}, 1),
}
}
// offer 落实 C.1 步骤 2~4:**先探槽**(满 ⇒ 合并,不过闸、不扣额度)→ **槽空才过闸**
// → 入槽;三步在同一把锁内完成,因此两个并发自排不会同时看到「槽空」而各排一轮。
//
// 返回值 queued 报告是否真排出了一轮(合并 = false)。
func (g *selfWakeGate) offer(req SelfWakeRequest) (SelfWakeReceipt, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.pending != nil {
// 合并路径**一次都不调用** Allow() —— 它成功即消耗,碰一下就是白扣额度。
return g.receiptLocked(true, true, ""), false
}
if !g.limiter.Allow() {
used := g.usedLocked()
receipt := SelfWakeReceipt{
Used: used,
Limit: g.limit,
WindowSec: int(g.window / time.Second),
Reason: fmt.Sprintf("已达自我唤醒上限(本窗口 %d/%d,窗口 %s),本轮未排队",
used, g.limit, g.window),
}
return receipt, false
}
g.granted = append(g.granted, time.Now())
queued := req
g.pending = &queued
select {
case g.ready <- struct{}{}:
default: // 已有一条通知在等:worker 取完槽后会再进内层循环,不会漏。
}
return g.receiptLocked(true, false, ""), true
}
// take 取走待跑槽(worker 用)。槽空 ⇒ ok=false,调用方回到等待。
func (g *selfWakeGate) take() (SelfWakeRequest, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.pending == nil {
return SelfWakeRequest{}, false
}
req := *g.pending
g.pending = nil
return req, true
}
// pendingDepth 报告待跑槽是否占用(探针用:判据 13 要确认合并、14 要确认槽已空)。
func (g *selfWakeGate) pendingDepth() int {
g.mu.Lock()
defer g.mu.Unlock()
if g.pending == nil {
return 0
}
return 1
}
// receiptLocked 组装一张收据(调用方必须持有 g.mu)。Used 在**消耗之后**取值。
func (g *selfWakeGate) receiptLocked(accepted, coalesced bool, reason string) SelfWakeReceipt {
return SelfWakeReceipt{
Accepted: accepted,
Coalesced: coalesced,
Reason: reason,
Used: g.usedLocked(),
Limit: g.limit,
WindowSec: int(g.window / time.Second),
}
}
// usedLocked 报告本窗口已用额度并顺手丢弃过期的记录(调用方必须持有 g.mu)。
//
// 它是**只读口径的镜像**:与 limiter 用同一个时间源(time.Now)与同一个窗口,
// 因此报了「已用几档」,而不参与任何判定。
func (g *selfWakeGate) usedLocked() int {
cutoff := time.Now().Add(-g.window)
keep := 0
for _, t := range g.granted {
if t.After(cutoff) {
g.granted[keep] = t
keep++
}
}
g.granted = g.granted[:keep]
return keep
}
// selfWakeLoop 是自排 worker(design/12 §N C.1):**单 goroutine** ⇒ 至多一轮自排在跑;
// 待跑槽容量 1 ⇒ 至多一轮自排在排队。两者合起来就是「排一次只跑一轮 + 最多额外欠 1 轮」。
//
// 它不做延时:槽里的请求立刻跑(重启不产生补跑风暴)。ctx 取消(Stop)时退出;
// 槽里若还留着一轮未跑,就地丢弃——没有持久化,也就没有欠账。
func (b *Backthink) selfWakeLoop(ctx context.Context) {
defer b.wg.Done()
gate := b.selfWake
if gate == nil {
return
}
for {
select {
case <-ctx.Done():
return
case <-gate.ready:
}
// 内层循环把当时可取的槽位全部取走:合并语义保证它至多取到一轮,
// 但循环写法让「取到空槽」成为正常出口,而不是靠时序假设。
for {
req, ok := gate.take()
if !ok {
break
}
b.runSelfWake(ctx, req)
}
}
}
// runSelfWake 跑一轮自排思考:折算走与事件同一条链(selfWakeTrigger → foldDeclared →
// escalateImport),随后完全复用既有的 think(记录、判定、推送、落库)。
func (b *Backthink) runSelfWake(ctx context.Context, req SelfWakeRequest) {
b.log.Info("backthink self wake round",
"reason", req.Reason, "declared", req.Declared.String())
b.think(ctx, selfWakeTrigger(req))
}