feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)

主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
This commit is contained in:
AskaEth committed 2026-10-24 21:11:22 +08:00
1 parent 679c8abf1d
commit ca8577f05b
21 files changed
+3616 -244

No files matched your search

@@ -0,0 +1,266 @@
package channelbridge
// coalesce_continuation_test.go 是 M23 的**回合中续跑**(⑤)与两个边界(陈旧度叠加、
// 停机诚实)的断言集,对应蓝图 §12 的 #11/#12/#13/#14/#17。
//
// ⑤ 的实现要点(蓝图 §5):**不存在 "turn in flight" 标志** —— 车道的单消费者性质
// 就是那个容器。回合在跑时 worker 不在循环头,新消息落进同一个队列;回合结束回到
// 循环头,takeAll 把这段期间攒下的整片取出 ⇒ **一轮续跑**(不是 N 轮、也不是 0 条)。
import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
"time"
)
// TestCoalesceMultiGroupIsolation(#11)断言作用域:两个群各 3 条交错到达 ⇒ 两批、
// 各自 3 条、互不串窗;同一平台的**两个账号**对同一个群则共用一条车道(与会话键同源,
// 是一个会话而不是两个 —— 见 lane.go 的 laneKey 取舍说明)。
func TestCoalesceMultiGroupIsolation(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-1", "9", "张三", fmt.Sprintf("a%d", i)))
f.send(coalesceGroupMsg("telegram", "-2", "9", "张三", fmt.Sprintf("b%d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (one per group)", got)
}
seen := map[string]int{}
for i := 0; i < chat.runCount(); i++ {
texts := userTextsIn(chat.runAt(i).messages)
if len(texts) != 3 {
t.Fatalf("batch %d has %d messages, want 3 (%v)", i, len(texts), texts)
}
prefix := texts[0][:1]
for _, text := range texts {
if text[:1] != prefix {
t.Fatalf("batch %d mixes groups: %v", i, texts)
}
seen[prefix]++
}
}
if seen["a"] != 3 || seen["b"] != 3 {
t.Fatalf("messages per group = %v, want a:3 b:3", seen)
}
// 多账号:同一个群的两个账号(同 platform / 同 channel)是**同一个会话** ⇒ 一条车道、
// 一批;回复走最后一条消息自己的适配器(车道本身不持有账号级状态)。
other := &laneAdapter{name: "telegram:conf1"}
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "9", "张三", "c0"), f.ad)
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "10", "李四", "c1"), other)
before := chat.runCount()
f.pump(t, func() bool { return chat.runCount() > before })
f.waitIdle(t)
if got := chat.runCount(); got != before+1 {
t.Fatalf("turns started = %d, want %d (two accounts of one group = one lane, one batch)", got, before+1)
}
if got := userTextsIn(chat.runAt(before).messages); len(got) != 2 {
t.Fatalf("multi-account batch = %v, want both messages in one turn", got)
}
}
// TestContinuationAfterTurnEnds(#12)断言⑤:回合进行中到达 3 条 ⇒ 回合结束后**一轮**
// 续跑吃掉 3 条(不是 3 轮、不是 0 条)。
func TestContinuationAfterTurnEnds(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate
}
}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.timers.advanceAndFire(t, f.clock) // 第一批到点发车
chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住)
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
}
// 模拟「第一轮花了 100ms」:因此这 3 条的到达时刻**早于**上一轮结束时刻,
// 它们才是「回合中到达」的续跑(真实时钟下这一步自然成立)。
f.clock.advance(100 * time.Millisecond)
close(gate)
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (the first one + exactly one continuation)", got)
}
if got := userTextsIn(chat.runAt(1).messages); strings.Join(got, ",") != "c0,c1,c2" {
t.Fatalf("continuation batch = %v, want [c0 c1 c2]", got)
}
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 {
t.Fatalf("continuations_total = %d, want 1", stats.ContinuationsTotal)
}
}
// TestContinuationNoDuplicateAndNoLoss(#13)断言⑤的「不重不丢」:每条消息的落库行恰好
// 1 条、每个 turn_id 恰好 1 条助手回复、`continuations_total==1`。
func TestContinuationNoDuplicateAndNoLoss(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate
}
}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.timers.advanceAndFire(t, f.clock) // 第一批到点发车
chat.waitRuns(t, 1, 3*time.Second)
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
}
f.clock.advance(100 * time.Millisecond)
close(gate)
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
userRows := map[string]int{}
assistantPerTurn := map[string]int{}
for _, row := range hist.snapshot("sess--20") {
switch row.Role {
case "user":
userRows[row.Content]++
case "assistant":
assistantPerTurn[row.TurnID]++
}
}
for _, text := range []string{"first", "c0", "c1", "c2"} {
if userRows[text] != 1 {
t.Fatalf("user row %q persisted %d times, want exactly 1", text, userRows[text])
}
}
if len(userRows) != 4 {
t.Fatalf("distinct user rows = %d, want 4: %v", len(userRows), userRows)
}
for turnID, n := range assistantPerTurn {
if n != 1 {
t.Fatalf("turn %s has %d assistant rows, want exactly 1", turnID, n)
}
}
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 || stats.CoalescedDroppedTotal != 0 {
t.Fatalf("stats = %+v, want one continuation and zero coalesced drops", stats)
}
}
// TestCoalesceWithStaleGate(#14)断言与陈旧度闸的叠加:批内 2 条超龄 + 1 条新鲜 ⇒
// 只跑新鲜的 1 条,超龄的逐条留痕;整批都超龄 ⇒ 不发车 + all_stale 计数。
//
// 顺序要求(蓝图 §5):**先**陈旧度**后**组上下文,且绝不让「批里最新一条新鲜」把超龄
// 的旧条也塞进上下文(M22「太旧不补答」优先)。
func TestCoalesceWithStaleGate(t *testing.T) {
t.Parallel()
rec := &laneRecorder{}
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
LaneDepth: 16,
OnRejected: rec.record,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old2"))
// 两条排着的时候时间越过陈旧度阈值,然后来了第三条新鲜的。
f.clock.advance(defaultLaneStaleAfter + time.Second)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "fresh"))
// worker 可能已经把前面两条按「绝对上限已过」自行发车了(全超龄 ⇒ 不发车),
// 也可能把三条并成一批 —— 两种时序下都要把这一批推到发车,因此按窗口触发(而不是
// 假定它「自己会跑」,那是调度时序,不是被测语义)。
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want 1 (only the fresh message may run)", got)
}
if got := userTextsIn(chat.runAt(0).messages); strings.Join(got, ",") != "fresh" {
t.Fatalf("batch user messages = %v, want [fresh] only", got)
}
if stats := f.b.LaneStats(); stats.DroppedStale != 2 {
t.Fatalf("dropped_stale = %d, want 2 (each stale message traced individually)", stats.DroppedStale)
}
if reasons := rec.reasons(); len(reasons) != 2 {
t.Fatalf("audit reasons = %v, want two lane_stale entries", reasons)
}
if stats := f.b.CoalesceStats(); stats.CoalescedDroppedTotal != 0 {
t.Fatalf("coalesced_dropped_total = %d, want 0 (stale drops are M22's gate, not coalescing)", stats.CoalescedDroppedTotal)
}
// 整批都超龄:一条都不发车,但留一条可查的 all_stale 痕迹。
allStale := newCoalesceBridge(t, Options{
Loop: &laneChatter{},
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
allStale.send(coalesceGroupMsg("telegram", "-30", "9", "张三", "gone"))
// 时钟越过陈旧度阈值:循环头看见「绝对上限已过」⇒ 直接按 max_wait 发车,
// 逐条判完发现整批都超龄 ⇒ 一条都不跑(这一步根本不建定时器)。
allStale.clock.advance(defaultLaneStaleAfter + time.Second)
allStale.waitIdle(t)
if got := allStale.chat.runCount(); got != 0 {
t.Fatalf("turns started = %d for an all-stale batch, want 0", got)
}
if stats := allStale.b.CoalesceStats(); stats.BatchesAllStaleTotal != 1 {
t.Fatalf("batches_all_stale = %d, want 1 (stats=%+v)", stats.BatchesAllStaleTotal, stats)
}
}
// TestCoalesceStopsHonestly(#17)断言停机诚实:桥在窗口内停止时,那一批**不会静默
// 消失** —— 有带 source 的 WARN(count=N)、`abandoned_at_stop_total` 增加。
func TestCoalesceStopsHonestly(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
// 走真实的生命周期:Start 建 b.ctx,车道 worker 与投递都用它,Stop 才取消得到它。
f.b.Start()
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.timers.take(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册)
f.b.Stop(context.Background())
if got := chat.runCount(); got != 0 {
t.Fatalf("turns started = %d after stop, want 0", got)
}
if stats := f.b.CoalesceStats(); stats.AbandonedAtStopTotal != 2 {
t.Fatalf("abandoned_at_stop_total = %d, want 2", stats.AbandonedAtStopTotal)
}
out := logs.String()
if !strings.Contains(out, "coalesce batch abandoned at bridge stop") ||
!strings.Contains(out, "source=services/channelbridge/coalesce") ||
!strings.Contains(out, "count=2") {
t.Fatalf("stop left no honest trace in the log:\n%s", out)
}
}