test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)

四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
This commit is contained in:
AskaEth committed 2026-10-24 22:24:14 +08:00
1 parent da6a365222
commit 7d9b2132bb
18 files changed
+407 -242

No files matched your search

@@ -59,7 +59,13 @@ func (f *fakeAdapter) Capabilities() adapter.Capabilities
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
// stubChatter 记录传给 agent 循环的会话 id 与消息。
//
// 🔴 字段一律经 mu 访问:Run 跑在**车道 worker** 上,测试 goroutine 在另一侧读记录 ——
// 「等一会儿再看循环有没有被驱动」这类负向断言之间**没有** happens-before。无锁时,
// 只要那一轮真被驱动了(正是负向断言要抓的失败形态),-race 就会把它报成 DATA RACE,
// 栈落在替身字段上、看起来像被测代码的问题;加锁不改并发度,只把记录的读写变成有序的。
type stubChatter struct {
mu sync.Mutex
sessionID string
messages []model.Message
runCtx context.Context
@@ -68,14 +74,60 @@ type stubChatter struct {
}
func (s *stubChatter) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = sessionID
s.messages = msgs
s.runCtx = ctx
return s.res, s.err
}
// SessionID 返回最近一轮的会话 id(空串 = 循环从未被驱动)。
func (s *stubChatter) SessionID() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessionID
}
// Messages 返回最近一轮的消息副本:调用方拿到的切片不与替身共享,
// 锁外的读写因此不会互相污染。
func (s *stubChatter) Messages() []model.Message {
s.mu.Lock()
defer s.mu.Unlock()
if s.messages == nil {
return nil
}
return append([]model.Message(nil), s.messages...)
}
// RunCtx 返回最近一轮的 ctx(turn id 接线用例靠它取回合标识)。
func (s *stubChatter) RunCtx() context.Context {
s.mu.Lock()
defer s.mu.Unlock()
return s.runCtx
}
// setResult 换掉本轮返回值:Run 之外改它同样要过锁。
func (s *stubChatter) setResult(res *loop.Result) {
s.mu.Lock()
defer s.mu.Unlock()
s.res = res
}
// clearSessionID 只清「循环被驱动过」这个记录,与旧用例里的 `chat.sessionID = ""`
// 逐字等价(消息与 ctx 照旧保留):让接下来的负向断言重新生效。
func (s *stubChatter) clearSessionID() {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = ""
}
// stubSessions 记录入站消息并返回固定会话。
//
// 与 stubChatter 同理:GetOrCreate 在车道 worker 上被调,测试 goroutine 读记录,
// 两侧没有 happens-before —— 记录必须过锁。
type stubSessions struct {
mu sync.Mutex
msg adapter.UnifiedMessage
owner *user.OwnerProfile
sess session.Session
@@ -83,12 +135,36 @@ type stubSessions struct {
}
func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) {
s.mu.Lock()
s.msg = msg
s.owner = owner
if s.err != nil {
return session.Session{}, s.err
err, sess := s.err, s.sess
s.mu.Unlock()
if err != nil {
return session.Session{}, err
}
return s.sess, nil
return sess, nil
}
// Msg 返回最近一次解析的入站消息。
func (s *stubSessions) Msg() adapter.UnifiedMessage {
s.mu.Lock()
defer s.mu.Unlock()
return s.msg
}
// Owner 返回最近一次解析的主人档案(未进白名单时为 nil)。
func (s *stubSessions) Owner() *user.OwnerProfile {
s.mu.Lock()
defer s.mu.Unlock()
return s.owner
}
// Sess 返回替身固定给出的会话。
func (s *stubSessions) Sess() session.Session {
s.mu.Lock()
defer s.mu.Unlock()
return s.sess
}
// directMessage 是一条主人私聊消息:platform telegram / 发送者 100。它是否是主人由**白名单**
@@ -132,18 +208,18 @@ func TestProcessRunsPipelineAndReplies(t *testing.T) {
b.process(ctx, directMessage())
if sess.msg.SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID)
if sess.Msg().SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.Msg().SenderID)
}
// 主人私聊被路由进唯一的 Web 主会话(design/16 §4.4)。
if chat.sessionID != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
}
// IM 平台上的主人私聊会镜像到主会话。来源由
// 最前面的一行系统上下文承载,用户文本保持不加装饰
// (design/16 §4.6.1):元数据绝不进入用户的自由文本通道。
var sys, userText string
for _, m := range chat.messages {
for _, m := range chat.Messages() {
if m.Role == model.RoleSystem {
sys = m.Content[0].Text
} else if m.Role == model.RoleUser {
@@ -177,8 +253,8 @@ func TestProcessSkipsEmptyText(t *testing.T) {
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Errorf("loop ran for empty content, session = %q", chat.sessionID)
if chat.SessionID() != "" {
t.Errorf("loop ran for empty content, session = %q", chat.SessionID())
}
if len(ad.sends) != 0 {
t.Errorf("send count = %d, want 0 for empty content", len(ad.sends))
@@ -203,7 +279,7 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
Content: "hello all",
ContentType: adapter.ContentText,
}
chat.res = &loop.Result{Text: "group ack"}
chat.setResult(&loop.Result{Text: "group ack"})
b.process(context.Background(), msg)
@@ -323,8 +399,8 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
t.Fatalf("persisted notifications = %d, want 2", len(persisted))
}
// 且循环针对主会话运行,而不是隔离的 sess-1。
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
}
}
@@ -413,8 +489,8 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
t.Fatalf("record[%d] Mode = %q, want chat", i, rec.Mode)
}
}
if chat.sessionID != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.SessionID())
}
// 每次隔离写入也会交给广播钩子,使 gateway 能为 IM
// 适配器日志页发出 `channel_persisted`——但消息本身
@@ -463,8 +539,8 @@ func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) {
// 被拉黑的群消息绝不进入 agent 循环,也绝不发送
// 回复;拒绝原因暴露给审计轨迹。
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.sessionID, chat.messages)
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(ad.sends) != 0 {
t.Fatalf("blacklisted message sent %d replies, want 0", len(ad.sends))
@@ -498,8 +574,8 @@ func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) {
// 来自不在允许白名单中的用户的私聊消息被丢弃;主人
// 绝不受影响(这里是陌生人)。
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.sessionID, chat.messages)
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(ad.sends) != 0 {
t.Fatalf("non-whitelisted user sent %d replies, want 0", len(ad.sends))
@@ -530,13 +606,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
// 循环看到最前面有一条第三方边界系统消息,加上已带
// [第三方 · 张三] 前缀的用户消息(历史中保留原始
// 内容,因此不丢失来源数据)。
if len(chat.messages) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
if len(chat.Messages()) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.Messages()))
}
if chat.messages[0].Role != model.RoleSystem || !strings.Contains(chat.messages[0].Content[0].Text, "非管理员账户") {
t.Fatalf("expected third-party boundary system message, got %+v", chat.messages[0])
if chat.Messages()[0].Role != model.RoleSystem || !strings.Contains(chat.Messages()[0].Content[0].Text, "非管理员账户") {
t.Fatalf("expected third-party boundary system message, got %+v", chat.Messages()[0])
}
user := chat.messages[1]
user := chat.Messages()[1]
// 第三方说话者由结构化的 SenderName 字段承载,用户文本保持
// 不加装饰(design/16 §4.6.1);来源/边界放在系统消息里。
if user.SenderName != "张三" || user.Content[0].Text != "hello all" {
@@ -575,18 +651,18 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
b.process(context.Background(), msg)
// 受限上下文 → 仍会注入第三方边界系统消息。
if len(chat.messages) != 2 || chat.messages[0].Role != model.RoleSystem {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
if len(chat.Messages()) != 2 || chat.Messages()[0].Role != model.RoleSystem {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.Messages()))
}
// 主人的消息不会被标为第三方,也不携带 SenderName;用户
// 文本保持不加装饰,群的来源放在系统消息里
// (design/16 §4.6.1)。
user := chat.messages[1]
user := chat.Messages()[1]
if user.SenderName != "" || user.Content[0].Text != "我在群里" {
t.Fatalf("owner group message = %+v, want undecorated text, no SenderName, no [第三方] prefix", user)
}
if !strings.Contains(chat.messages[0].Content[0].Text, "群#-20") {
t.Fatalf("origin/boundary system = %q, want group#-20", chat.messages[0].Content[0].Text)
if !strings.Contains(chat.Messages()[0].Content[0].Text, "群#-20") {
t.Fatalf("origin/boundary system = %q, want group#-20", chat.Messages()[0].Content[0].Text)
}
// 主人在群里的发言仍落在隔离会话(不是 Web 主会话)。
recs := hist.records()
@@ -603,8 +679,8 @@ func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) {
if len(ad.sends) != 1 {
t.Fatalf("send count = %d, want 1", len(ad.sends))
}
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
}
}