feat(channelbridge): 入站投递与回水解耦 + 每会话回合车道(M22)

报障:「群聊消息速率限制似乎会让消息一直排队,导致后面才回复前面的话」。
病灶:`onMessage` 在适配器的读循环里**同步**抢全局 16 槽回合信号量,槽满即停止
读 socket(心跳不回、后续帧不解析),跑完一轮再按条重放。

- 解耦:`onMessage` 过完流控闸后只调用 `enqueueInbound` 就返回;全局回合槽
  (maxConcurrent=16)搬到车道 worker 里取,读循环不再等任何一个回合。
- 每会话一条车道(lane.go):键 = `<账号>|<platform>|<channel>|<会话桶>`
  (主人私聊恒为 `main|web`,因为它们都路由进同一个主会话);同会话串行 FIFO、
  不同会话并行;空闲车道退休,表大小只跟活跃会话数相关。
- 陈旧度闸:轮到时已等超 `LaneStaleAfter`(默认 30s,Options 可覆盖)⇒ 丢弃不补答,
  留 WARN + LaneStats 计数 + onRejected 审计。主人私聊与非聊天事件豁免
  (主人 2026-10-24 裁定:宁可晚回,不许不回)。
- 背压有界且诚实:群聊/陌生人满时在门前拒收这一条(队列内容不动,M23 的 takeAll
  依赖它);主人私聊永不拒收,溢到 spill 只排队,越过 ownerLaneBacklogWarn 记 WARN。
- 四条不变量(顺序/一致性/完整性/最新性)在 lane.go 头部写清,lane_test.go 逐条断言;
  TestLaneDeliveryNeverBlocksTheReadLoop 用「槽全满 + 心跳帧」复现旧病灶。

不改「回什么」的语义:flowgate 限流判据、engage/冷避/owner 闸、陌生人 AcquireTurn
全部原样保留;不做静默窗口合并与回合中续跑(M23)。
This commit is contained in:
AskaEth committed 2026-10-24 19:46:20 +08:00
1 parent 4ed5925296
commit e91b868a5c
4 files changed
+1136 -26

No files matched your search

+15 -26
View File
@@ -59,9 +59,10 @@ func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) {
b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name)
}
// onMessage 是面向适配器的入站回调。它在有界 goroutine 中运行
// 流水线,使适配器的读取循环不会因缓慢的 agent 轮次而阻塞。
// 适配器被显式携带,使流水线知道是哪个账号收到了
// onMessage 是面向适配器的入站回调。它**只投递、不跑回合**:过完流控闸就把消息投进
// 该会话自己的回合车道(lane.go)并立即返回,因此适配器的读循环(WS 单线程读 socket /
// iLink 长轮询 / webhook 处理器)永远不会被一轮缓慢的 agent 调用阻塞 —— 心跳与其它帧
// 照常被读、被解析。适配器被显式携带,使流水线知道是哪个账号收到了
// 消息(多账号回复路由 + 按账号允许/拒绝,design/16
// §4.6 §5)。
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
@@ -94,29 +95,17 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// 在 Stop 设置 closed 所用的同一把锁下 Add 工作项,使
// 并发的 Stop 要么在等待前看到我们的 Add,要么我们
// 观察到 closed 后退出,不泄露 goroutine。
b.mu.Lock()
if b.closed {
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
// M22(切片 LANEQUEUE)——**读与回水解耦**:流控闸在读循环里仍然是同步的
// (一次滑动窗口判断,微秒级;洪泛正是在这里被挡掉,不该让它进车道占内存),
// 闸后立刻把消息投进**该会话自己的回合车道**并返回,读循环因此马上可以继续读
// socket —— 心跳与其它帧不再被一轮模型调用阻塞(此前这里同步抢 16 个全局回合槽,
// 槽满即停止读 socket,这正是「消息一直排队、后面才回复前面的话」的现场)。
//
// 入队非阻塞、有界(背压见 lane.go 的 offer):群聊/陌生人车道满时**在门前拒收
// 这一条**并留痕(WARN + 计数 + 审计),队列里已有的条目一条不动;主人私聊永不
// 拒收,溢到 spill 只排队。回合本身在 laneWorker 里跑,全局并发上限
// (maxConcurrent)也搬到那里取。
b.enqueueInbound(ctx, msg, a, actor)
}
// process 用平台与消息匹配的适配器运行入站流水线