fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可): - 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来 - 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知) 后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库) 载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」 前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级) + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表) + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result) + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑) 双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次 契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
This commit is contained in:
1 parent
1bf85a955b
commit
84c124f5a9
18 files changed
+1545
-46
No files matched your search
@@ -72,19 +72,21 @@ func (p *mockPusher) count() int { p.mu.Lock(); defer p.mu.Unlock(); return p.ca
|
||||
|
||||
// mockActive 实现 ActiveClient。
|
||||
type mockActive struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
err error
|
||||
calls int
|
||||
last adapter.UnifiedMessage
|
||||
mu sync.Mutex
|
||||
n int
|
||||
persisted bool
|
||||
err error
|
||||
calls int
|
||||
last adapter.UnifiedMessage
|
||||
}
|
||||
|
||||
func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
|
||||
func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ProactiveDelivery, error) {
|
||||
m.mu.Lock()
|
||||
m.calls++
|
||||
m.last = msg
|
||||
n, persisted, err := m.n, m.persisted, m.err
|
||||
m.mu.Unlock()
|
||||
return m.n, m.err
|
||||
return ProactiveDelivery{Connections: n, Persisted: persisted}, err
|
||||
}
|
||||
|
||||
// mockIMAdapter 实现 IMAdapter。
|
||||
|
||||
@@ -69,13 +69,35 @@ func (p PushResult) String() string {
|
||||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
|
||||
}
|
||||
|
||||
// ProactiveDelivery 是一次活跃客户端投递的结果(切片 P0「她主动找你」)。
|
||||
// 它把**两件本就独立**的事分开报告:帧送到了几条连接,以及这条消息有没有
|
||||
// 成为主会话里的一条真实消息。
|
||||
//
|
||||
// 为什么要分开:修复前活跃客户端路由只广播 WS 帧、不落库,于是面板上的
|
||||
// 「delivered to 2 active client(s)」被读成「已推送」,而刷新后什么都没剩
|
||||
// —— 那正是主人报的「后台思考的主动推送一个都没有推出来」。落库与投递各自
|
||||
// 成败,记录里就必须各自可见。
|
||||
type ProactiveDelivery struct {
|
||||
// Connections 是接受该帧的在线 owner/admin 连接数。0 意味着此刻没有
|
||||
// 在线目标;它**不代表消息丢了**——消息可能已经落库(见 Persisted)。
|
||||
Connections int
|
||||
// Persisted 报告该消息是否已成为主会话历史(`history.MainSessionID`)里的
|
||||
// 一条真实消息,因而刷新/重载后仍在。
|
||||
Persisted bool
|
||||
}
|
||||
|
||||
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
|
||||
// 客户端,并报告有多少个收到。它是首选的推送目标。
|
||||
// 客户端,并报告投递与落库两件事。它是首选的推送目标。
|
||||
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
|
||||
type ActiveClient interface {
|
||||
// SendProactive 把 msg 推给每个活跃客户端,并返回
|
||||
// 接受它的客户端数量。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
|
||||
// ProactiveDelivery。
|
||||
//
|
||||
// 实现契约(切片 P0):**先落库、再发帧**,且发帧的载荷带上落库行的 id ——
|
||||
// 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在)。落库失败时
|
||||
// 仍要发出帧(实时气泡不能丢),但必须把错误返回且 Persisted=false,
|
||||
// 让调用方如实记录「发出去了、但没留下记录」。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ProactiveDelivery, error)
|
||||
}
|
||||
|
||||
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
|
||||
@@ -246,19 +268,43 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
|
||||
}()
|
||||
}
|
||||
|
||||
// pushActive 只投递给活跃 Web/原生客户端。
|
||||
// pushActive 只投递给活跃 Web/原生客户端。它按 ProactiveDelivery 如实报告
|
||||
// 「送到了几条连接」与「有没有落进主会话」(切片 P0):投递数为 0 时消息仍可能
|
||||
// 已经是一条真实记录,两者绝不互相冒充。
|
||||
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.active != nil {
|
||||
n, err := p.active.SendProactive(ctx, msg)
|
||||
if err != nil {
|
||||
p.log.Warn("backthink active client push failed", "error", err)
|
||||
} else if n > 0 {
|
||||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: fmt.Sprintf("delivered to %d active client(s)", n)}
|
||||
}
|
||||
if p.active == nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
d, err := p.active.SendProactive(ctx, msg)
|
||||
if err != nil {
|
||||
p.log.Warn("backthink active client push failed", "error", err)
|
||||
}
|
||||
if d.Connections > 0 {
|
||||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: activeDeliveryDetail(d)}
|
||||
}
|
||||
if d.Persisted {
|
||||
// 此刻没有在线客户端,但这条话已经成为主会话里的一条真实记录:打开/
|
||||
// 刷新页面就能看到,只是没有实时帧可发。如实分别报告这两件事,面板才不会
|
||||
// 把「没推出去」与「没留下」混为一谈。
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client, recorded in main session"}
|
||||
}
|
||||
if err != nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client", Err: err.Error()}
|
||||
}
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
|
||||
// activeDeliveryDetail 是活跃客户端路由的 Detail 措辞:投递数与「有没有落进
|
||||
// 主会话」两件事实都在里面,且**绝不**只说「已推送」(那正是被误读成「刷新还在」
|
||||
// 的原话)。
|
||||
func activeDeliveryDetail(d ProactiveDelivery) string {
|
||||
state := "not persisted to main session"
|
||||
if d.Persisted {
|
||||
state = "persisted to main session"
|
||||
}
|
||||
return fmt.Sprintf("delivered to %d active client(s), %s", d.Connections, state)
|
||||
}
|
||||
|
||||
// pushIM 只投递给 IM 桥。
|
||||
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.im == nil {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 P0「她主动找你」的**活跃客户端路由口径**:
|
||||
//
|
||||
// - PushResult.Detail 必须把「送到了几条连接」与「有没有落进主会话」**分开**
|
||||
// 报告 —— 修复前那句「delivered to 2 active client(s)」被读者当成「已推送
|
||||
// (刷新也在)」,而当时一行都没落库;
|
||||
// - 一次推送**至多让一个 sink 成功**(IM 桥与活跃客户端互斥),这正是
|
||||
// 「主会话里不会出现双份」的结构性依据:两条路由各自落库一次,而一次推送
|
||||
// 只走其中一条。
|
||||
|
||||
// TestPusher_ActiveDetailReportsPersistence 验证 Detail 分别报告投递数与落库。
|
||||
func TestPusher_ActiveDetailReportsPersistence(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
active *mockActive
|
||||
wantPushed bool
|
||||
wantIn string
|
||||
wantNotIn string
|
||||
}{
|
||||
{
|
||||
name: "投递成功且已落库",
|
||||
active: &mockActive{n: 2, persisted: true},
|
||||
wantPushed: true,
|
||||
wantIn: "persisted to main session",
|
||||
wantNotIn: "not persisted",
|
||||
},
|
||||
{
|
||||
name: "投递成功但未落库(故障可见)",
|
||||
active: &mockActive{n: 2, persisted: false},
|
||||
wantPushed: true,
|
||||
wantIn: "not persisted to main session",
|
||||
},
|
||||
{
|
||||
name: "无在线客户端但已落库",
|
||||
active: &mockActive{n: 0, persisted: true},
|
||||
wantPushed: false,
|
||||
wantIn: "recorded in main session",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newPusher(tc.active, nil, &mockGate{allow: true}, discardLog())
|
||||
res := p.Push(context.Background(), PushRequest{
|
||||
ChannelHint: channelWeb,
|
||||
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
|
||||
})
|
||||
if res.Pushed != tc.wantPushed {
|
||||
t.Fatalf("Pushed = %v, want %v (res=%+v)", res.Pushed, tc.wantPushed, res)
|
||||
}
|
||||
if !strings.Contains(res.Detail, tc.wantIn) {
|
||||
t.Fatalf("Detail = %q, want it to contain %q", res.Detail, tc.wantIn)
|
||||
}
|
||||
if tc.wantNotIn != "" && strings.Contains(res.Detail, tc.wantNotIn) {
|
||||
t.Fatalf("Detail = %q, must not contain %q", res.Detail, tc.wantNotIn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusher_OnePushSucceedsOnAtMostOneSink 验证一次推送**至多让一个 sink 成功**
|
||||
// —— 互斥是「主会话里不会双份」的结构性依据。IM 失败后的活跃客户端降级是同一
|
||||
// 次推送内的兜底:失败的 IM 什么都没写,因此整体仍只有一条记录。
|
||||
func TestPusher_OnePushSucceedsOnAtMostOneSink(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
hint string
|
||||
imErr error
|
||||
activeN int
|
||||
wantIMCalls int
|
||||
wantActiveCall int
|
||||
}{
|
||||
{name: "web 提示只打活跃客户端", hint: channelWeb, activeN: 1, wantIMCalls: 0, wantActiveCall: 1},
|
||||
{name: "auto 提示 IM 可用时只打 IM", hint: channelAuto, activeN: 2, wantIMCalls: 1, wantActiveCall: 0},
|
||||
{name: "IM 失败时降级到活跃客户端", hint: channelAuto, imErr: errors.New("no owner IM channel"), activeN: 1, wantIMCalls: 1, wantActiveCall: 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
active := &mockActive{n: tc.activeN, persisted: true}
|
||||
im := &mockIMAdapter{err: tc.imErr}
|
||||
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
|
||||
|
||||
res := p.Push(context.Background(), PushRequest{
|
||||
ChannelHint: tc.hint,
|
||||
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
|
||||
})
|
||||
if im.count() != tc.wantIMCalls || active.calls != tc.wantActiveCall {
|
||||
t.Fatalf("sink calls: im=%d (want %d) active=%d (want %d)",
|
||||
im.count(), tc.wantIMCalls, active.calls, tc.wantActiveCall)
|
||||
}
|
||||
// 成功的 sink 至多一个:能落库的路径因此也至多一条。
|
||||
imOK := tc.imErr == nil && tc.wantIMCalls == 1
|
||||
activeOK := tc.activeN > 0 && tc.wantActiveCall == 1
|
||||
if imOK && activeOK {
|
||||
t.Fatalf("both sinks succeeded in one push (res=%+v)", res)
|
||||
}
|
||||
if !imOK && !activeOK && res.Pushed {
|
||||
t.Fatalf("Pushed = true without a successful sink (res=%+v)", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user