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), } }