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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user