Files
Cyrene_v2/internal/services/channelbridge/lane.go
T
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00

190 lines
10 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 channelbridge
// 本文件承载入站的**回合车道**(M22 切片 LANEQUEUE)。
//
// 报障现场(主人 2026-10-24):「群聊消息速率限制似乎会让消息一直排队,导致后面才回复
// 前面的话。」病灶不在模型侧,而在**入站入口把回合跑在了适配器的读循环里**:
// `onMessage` 曾在读循环里**同步**抢全局回合槽(`b.sem`,16 个),槽满的那一刻读循环
// 就停止读 socket —— 心跳不被回应、后续帧不被解析,等一轮跑完再按条重放,于是先回旧话、
// 而且回得晚。本文件把「投递」与「跑回合」拆开,并给每个会话一条自己的车道。
//
// - **读与回水解耦**:适配器的读循环只投递、不跑回合(`enqueueInbound` 立刻返回),
// 心跳与其它帧因此永远不会被一轮模型调用堵住。全局回合槽(`b.sem`)搬到车道 worker
// 里取,仍然限制「最多 16 个回合同时跑」,但等待发生在线程池里而不是读循环里。
// - **每会话一条回合车道**:同一会话(群 / 私聊各自键,见 `laneKey`)的回合**串行**且
// **先来先跑**(车道是一条有界 FIFO + 一个 worker),不同会话可并行。
// - **陈旧度闸**:轮到某条时它已超龄 ⇒ 丢弃不补答,并留下 WARN + 计数 + 审计。
// - **背压有界且诚实**:每会话车道深度有界。群聊 / 陌生人满时**在门前拒收这一条**
// (队列里已有的条目一条都不动);**主人私聊永不拒收**,只排队(见 offer)。
//
// 本切片**不改「回什么」的语义**:该回的回、该静的仍然静,也没有新增任何「系统繁忙」
// 式回复 —— 丢弃只走审计轨(与 flowgate 的 rate_limited / review_pruned 同一条流水)。
// 静默窗口合并、回合中来了新消息就续跑一轮,属于下一片(M23),不在这里做。
//
// 四条不变量(每条都在 lane_test.go 里有一条断言):
//
// ① 顺序:同一会话内的回复顺序与到达顺序一致(FIFO 车道 + 单 worker)。
// ② 一致性:一轮内上下文是快照,同一会话绝不会有两个回合同时跑(车道串行;
// 主人私聊跨平台共用一条 `main` 车道,因为它们都路由进同一个 Web 主会话)。
// ③ 完整性:被丢的只有「超龄」与「背压」两类,两者都留 WARN + 计数 + 审计。
// ④ 最新性:回合上下文在**出队之后**才装配(processFrom 原样保留,见 runLaneItem),
// 所以一条排队中的消息看到的是它开始那一刻的最新历史,而不是入队时的快照。
import (
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
const (
// laneLogSource 是车道的子功能日志来源(AGENTS §10:日志必须标来源)。
laneLogSource = "services/channelbridge/lane"
// defaultLaneDepth 是**每会话**车道能排的条目数。取值依据:
// - 够吸收一次「连着发几条」的群聊突发(微信/QQ 里 5-8 条连发是常态,
// 尤其是几个人同时在说话而她正在答上一个问题);
// - 又小到车道永远不会变成「按条补答」的长队伍:一轮 IM 回合(含工具循环)
// 通常 3-20s,深 8 的车道在最坏情况下意味着约 2-3 分钟的滞后,那时排在后面的
// 条目大多已过陈旧度闸,多留只会多丢;
// - 内存上界随之确定:条目数 ≤ 活跃会话数 × 8,绝不无界增长。
defaultLaneDepth = 8
// defaultLaneStaleAfter 是**陈旧度闸**的默认阈值(可经 Options.LaneStaleAfter 覆盖,
// 见 channelbridge.go)。取值依据:
// - 一轮 IM 回合(含工具循环)实测多在 3-20s 这一档,30s ≈ 两倍上限;
// - 群聊里超过半分钟的「回答」在用户看来已经是「答上一句」,补它比不补更糟
// (正是报障里的「后面才回复前面的话」);
// - 比它更短会误伤正常的「她正在打字」等待(一个人等她回答 10-20s 是正常的)。
defaultLaneStaleAfter = 30 * time.Second
// laneDropReasonStale / laneDropReasonOverflow 是两类**唯一**允许的丢弃原因,
// 进 onRejected 审计轨时用的原因标记(与 flowgate 的 rate_limited /
// review_pruned / capped 同一口径,面板与排障据此聚合)。
laneDropReasonStale = "lane_stale"
laneDropReasonOverflow = "lane_overflow"
// ownerLaneBacklogWarn 是主人私聊溢出区的告警水位:越过它(并按倍率 16/32/64…)
// 记一条 WARN,表明「她落后了这么多条主人私聊」—— 这是主人私聊车道的**唯一**可观测
// 上界信号,它不丢任何一条(主人 2026-10-24 裁定:宁可晚回,不许不回)。
ownerLaneBacklogWarn = 16
// ownerPrivateLaneKey 是主人私聊车道的键:那条 lane 对应的是「Web 主会话」这个
// **目的地**(inboundTargetFor 把各平台的主人私聊都路由进去),因此与会话键故意不同。
ownerPrivateLaneKey = "main|" + history.MainSessionID
// laneKeySep 只用于「没有可用渠道」的兜底键(见 laneKey);真实车道的键逐字来自
// session.Key,本来就是 `platform \x00 channelID`。
laneKeySep = "\x00"
)
// laneItem 是车道上的一条待办:入站消息 + 它的账号适配器 + 已解析出的 actor。
//
// actor 在入口(onMessage)已经解析过一次(结构化判据,不读正文),这里带着走,
// 使陈旧度闸不必重解析,也不必为了判「主人私聊」而多一次会话查询。
type laneItem struct {
msg adapter.UnifiedMessage
a adapter.PlatformAdapter
actor adapter.Actor
arrivedAt time.Time
}
// lane 是一个会话的回合通道:有界 FIFO + 一个 worker(串行 ⇒ 不变量 ①②)。
//
// neverDrop 只对**主人私聊**车道为真:它满了不拒收,条目改落 spill(溢出区,只排队)。
// spill 恒为空的车道因此不占额外内存。
//
// 车道分**两种类型**(M23 的红线级结构约束,见下面两个构造函数):
// - **direct 车道**(主人私聊):到达即投递、一条一条跑。它**没有** batcher 字段值,
// 因此拿不到定时器工厂 —— 「主人私聊没有静默窗口」是**类型上不可达**,
// 不是配置判出来的(配置无权翻转,见 newDirectLane)。
// - **batch 车道**(群聊 / 陌生人私聊):持有一个 batchWindow,由窗口引擎决定何时发车。
type lane struct {
key string
ch chan laneItem
neverDrop bool
spill []laneItem
// front 是「回插队首」区:单批超过上限时,尾部**原样保序**地插回这里,
// 成为下一批(M23 蓝图 §4-3:超量不是丢弃)。front 里的条目一定早于
// ch 与 spill 里的,因此读取顺序恒为 front → ch → spill。
front []laneItem
// batcher 非 nil ⇒ 这是一条 batch 车道(持有静默窗口的定时器工厂)。
// nil ⇒ direct 车道:窗口/定时器对它是**不可达类型**。
batcher *batchWindow
// lastTurnEnd 是该车道**上一轮回合结束**的时刻(只有 worker 读写 ⇒ 无需加锁)。
// 它只服务 continuations_total:批内首条在上一轮结束前就已到达 ⇒ 这批是⑤续跑。
lastTurnEnd time.Time
}
// newDirectLane 构造**主人私聊**的 direct 车道:它没有任何窗口状态,也不持有定时器
// 工厂(不传 batchWindow ⇒ 拿不到)。
//
// 🔴 车道类型由 **actor 元数据**决定(判据 `ownerPrivateLane`:`!msg.IsGroup() &&
// actor.IsOwner()`,与 M22 的车道键/丢弃策略同源),配置无权翻转:唯一能选 direct
// 的地方是 enqueueInbound 的 ownerPrivateLane 判定。主人私聊是
// 「她和我说话」,2s 迟滞在私聊里没有任何收益(只有被拖慢的观感),
// 且 M22 已裁定主人私聊永不丢弃、不受陈旧度闸约束 —— 本函数是同一取向的延续。
func newDirectLane(key string, ch chan laneItem, neverDrop bool) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop}
}
// newBatchLane 构造**群聊 / 陌生人私聊**的 batch 车道:窗口引擎只在这里可达。
func newBatchLane(key string, ch chan laneItem, neverDrop bool, batcher *batchWindow) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop, batcher: batcher}
}
// laneSet 是全部活跃车道的注册表。车道在**空闲时退休**(worker 见队列空即把自己摘掉),
// 因此表的大小只与「此刻有排队/在跑的会话数」相关。
type laneSet struct {
depth int
staleAfter time.Duration
log *slog.Logger
// after 是静默窗口的定时器工厂(M23):**只**在构造 batch 车道时被塞进
// batchWindow,direct 车道拿不到它(见 newDirectLane)。
after func(time.Duration) <-chan time.Time
mu sync.Mutex
lanes map[string]*lane
droppedStale int64
droppedOverflow int64
}
// laneSpec 描述「这条车道该长什么样」。类型(direct / batch)由**actor 元数据**决定,
// 调用方(enqueueInbound)算好传进来;配置只能影响窗口参数,改不了类型。
type laneSpec struct {
// neverDrop ⇒ 主人私聊:队列满时不拒收,落 spill 只排队。
neverDrop bool
// batch ⇒ 群聊/陌生人私聊:持有静默窗口(direct 车道不持有)。
batch bool
// capacity 是队列深度:batch 车道至少能装下一整批(否则「12 条成 1 轮」在
// 结构上不可达 —— 第 9 条就会在门前被拒收)。
capacity int
}
// newLaneSet 构造车道注册表;depth / staleAfter 为 0 时取上面的默认值,
// after 为 nil 时取 time.After。
func newLaneSet(depth int, staleAfter time.Duration, after func(time.Duration) <-chan time.Time, log *slog.Logger) *laneSet {
if depth <= 0 {
depth = defaultLaneDepth
}
if staleAfter <= 0 {
staleAfter = defaultLaneStaleAfter
}
if after == nil {
after = time.After
}
if log == nil {
log = logger.New(logger.Options{})
}
return &laneSet{
depth: depth,
staleAfter: staleAfter,
log: logger.WithSource(log, laneLogSource),
after: after,
lanes: make(map[string]*lane),
}
}