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 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
190 lines
10 KiB
Go
190 lines
10 KiB
Go
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),
|
||
}
|
||
}
|