Files
MailUI4Agents/server/internal/repo/agentloop_test.go
dsh 974bf2a62f fix(server): ★ relay 邮件不计入 Agent 互发上限 + 该闸放行 relay 发送 —— 修「退信把通道自己锁死」的会话死锁
症状(生产会话 0094eee5 `harmony-push-真机验证`):
homeagent 的 3 封失败退信(relay:"summary")被 CountTrailingAgentPingPong
计入「Agent 互发」计数,与 5 封模型主动往返合计 8 ⇒ 触发上限。之后:
  · 模型每次真生成完回复再 send ⇒ 403(网关日志 09-29 22:01 ~ 09-30 10:11 共 7 笔);
  · 桥的 sendFailureReply 退信也走 /mail/send ⇒ 同样 403 ⇒ 发件人只收到
    「模型未产生回复」,真因被吞;
  · 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ 死锁,无人能解。
发件方收到的错误与「模型没说话」在桥侧合并成同一文案(plugin.go:1022),
把内核侧限流误报成模型故障 —— 两个 Agent 各自查错了方向一整天。

根因:2026-09-26 设计第三道闸时的实测样本(pi↔dsh 175 封)里 relay=0,
于是默认这条路径上没有 relay 邮件。但插件代劳的退信同样满足
「Agent→Agent + 无人类」,被并进同一个闸。relay 本有自己的更严闸
(maxRelayHops=5),两类回路挤在同一个计数里是设计疏漏。

修法(两处,缺一不可):
  1. repo:CountTrailingAgentPingPong 跳过 relayed_mails 里登记过的邮件
     (LEFT JOIN,与 CountTrailingRelayHops 同一判据源)。
  2. handler:该闸加 `relay == ""` 条件 —— 即使计数已满,插件代劳的
     退信/转发也必须能发出去(否则 ①② 仍在:闸拦住退信 → 真因被吞)。

红绿(生产同款数据形状):
  3 主动 + 3 relay + 2 主动 ⇒ 缺陷版数出 8(FAIL,与生产实测一字不差),
  修复后 5(PASS)。人类参与打断连续性的语义不变(回归 TestAgentPingPongResetsOnHuman)。

自证边界:单测证明的是计数器与闸门的判据;「死锁会话已解锁」要在部署后
用真实会话验证(见下一笔提交)。403 文案同时删掉了「或说明为何这轮必须继续」
—— 模型的任何说明本身也要走 send,被同一道闸拦着,这条恢复路径不存在。
2026-09-30 17:08:49 +08:00

223 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
★ 第三道防线:数**无人决策的 Agent↔Agent 连续往返**(与 relay 无关)。
# 这个缺陷的形状(2026-09-26 生产实测)
`pi ↔ dsh` 一天 175 封、正文 612KB,其中 **relay = 0**:全部是模型**主动**
调 send_mail。而既有两道防线都只覆盖 `relay != ""`:
· 会话预算 —— relay 才扣
· maxRelayHops —— `if relay != "" {` 根本没进
· 插件自动转发守卫 —— 日志明说"本轮不自动转发",守卫正常,但不管模型主动发
⇒ 三条全绕开。这三条判据钉住第四道的**判据形状**(连续性、人类参与即归零),
而不是钉"有没有这个函数"。
*/
/*
seedPingPong 造一封 Agent 之间的信。
★ **必须显式给 created_at,且逐封递增。**
`mails.created_at` 的默认值是 `CURRENT_TIMESTAMP`(**秒**精度),而
`CountTrailingAgentPingPong` 按 `created_at DESC, mail_id DESC` 从后往前扫。
同一秒内插的多封,排序实际由随机 UUID 决定 ⇒ 连续段数**不确定**。
我第一版就是这么写的,当场被这条判据自己抓到:同一段数据一次读出 2、一次读出 1
("人类插话后应只数它之后的 2 封,实际 1")。这与本仓记录过的老坑同一个形状
("SQLite 时间戳只有秒精度:同秒多封排序不确定")。
生产侧不受影响:`mails` 的 INSERT 显式传 `NOW()`(微秒)。这里补上同样的事。
*/
var pingPongSeq int
func seedPingPong(t *testing.T, sessionID uuid.UUID, from, to string) {
t.Helper()
pingPongSeq++
// 以固定基准 + 递增秒数,确保先后顺序与插入顺序一致
ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(pingPongSeq) * time.Second)
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO mails (session_id, from_name, to_name, subject, body, created_at)
VALUES ($1, $2, $3, 'pingpong', 'b', $4)`, sessionID, from, to, ts); err != nil {
t.Fatal(err)
}
}
func TestAgentPingPongCountsConsecutive(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "回路", "")
if err != nil {
t.Fatal(err)
}
// 三个来回 = 6 封,全是 Agent↔Agent(from/to 都不是人类用户名)
for i := 0; i < 3; i++ {
seedPingPong(t, sid, "pi", "dsh")
seedPingPong(t, sid, "dsh", "pi")
}
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 6 {
t.Fatalf("三个来回应为 6,实际 %d(err=%v)", n, err)
}
}
// ★ 核心语义:**人类插一句话,计数归零**。
//
// 这一条决定了这个判据会不会误伤正常用法:
// · 人类说一句 → Agent 回十封 → 人类再一句 ⇒ 每次都在阈值以下
// · 没有人说话 → 一直涨 ⇒ 到阈值拒绝
// 与 maxRelayHops 的"连续"同构 —— 两条防线在这个语义上必须一致,
// 否则同一个现象在两处得到不同的结论。
func TestAgentPingPongResetsOnHuman(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "回路", "")
if err != nil {
t.Fatal(err)
}
/*
★ 判据依赖 "jianf 是人类" —— 而测试库是空的(`setupTestDB` 只跑迁移,
不建用户)。不种的话 `IsHumanUser` 查不到,这封信会被当成 Agent 发的,
于是一条本应绿的判据变红。
我第一版就漏了这一步,当场被这条判据自己抓到("应只数 2 封,实际 3")。
⇒ 判据里任何"某名字是人类/Agent"的前提都必须**显式种下**,
不能靠库里碰巧有。
*/
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('jianf', 'jianf', 'x', 'admin')`); err != nil {
t.Fatal(err)
}
// 先造一封人类的信(计数应当从它之后才开始)
seedPingPong(t, sid, "jianf", "pi")
seedPingPong(t, sid, "pi", "dsh")
seedPingPong(t, sid, "dsh", "pi")
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 2 {
t.Fatalf("人类插话后应只数它之后的 2 封,实际 %d(err=%v)", n, err)
}
// 人类**收**信也算打断(to 是人类)—— 不是只认发件方
seedPingPong(t, sid, "pi", "jianf")
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 0 {
t.Fatalf("人类收信也应打断连续性(应 0),实际 %d(err=%v)", n, err)
}
}
// 反向对照:**没有人类参与**时计数不该被重置 —— 否则这道防线永远不触发
// (把"人类参与才 reset"实现成"每封都 reset",上面那条仍会绿)。
func TestAgentPingPongNotResetByAgents(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "回路", "")
if err != nil {
t.Fatal(err)
}
seedPingPong(t, sid, "pi", "dsh")
seedPingPong(t, sid, "opencode", "homeagent") // 换一对 Agent 也仍是"无人决策"
seedPingPong(t, sid, "dsh", "pi")
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 3 {
t.Fatalf("纯 Agent 往返应连续计数(应 3),实际 %d(err=%v)", n, err)
}
if MaxAgentPingPong() <= 0 {
t.Fatal("阈值必须为正,否则守卫恒真或恒假")
}
}
/*
★ 2026-09-30 缺陷(dsh,生产实测 `harmony-push-真机验证` 会话 0094eee5):
插件代劳的邮件(失败退信 / 权限询问 / 总结转发,走 relay:"summary" 免配额通道)
被 CountTrailingAgentPingPong 一并计入「Agent 互发」计数。它同样满足
「发件方 Agent + 收件方 Agent + 无人类」,于是:
① 退信把计数顶到 8 ⇒ 闸被**插件自己**触发;
② 闸在 send 入口、先于 relay 分支 ⇒ 退信本身也被 403;
③ 退信发不出去 ⇒ 发件人只看到「模型未产生回复」(桥的 sendFailureReply
同样发不出,真因被吞);
④ 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ **会话永久死锁**:
模型回信 → 403 → 退信也 403 → 发件人重发 → 模型再回 → 再 403。
生产上该会话 09-29 22:01 至 09-30 10:11 被 403 拒了 7 次。
正确形状:relay 邮件归 CountTrailingRelayHops(阈值 5,更严)管,
不挤占「模型主动互发」的额度(阈值 8)。两条闸独立计数、互不侵占。
*/
func seedPingPongRelay(t *testing.T, sessionID uuid.UUID, from, to string) {
t.Helper()
seedPingPong(t, sessionID, from, to) // 复用同一套时间戳纪律(见其注释)
// 给刚插入的那封登记 relayed_mails(服务端成功路径由 ClaimRelay+BindRelayMail 做)
var mailID string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT mail_id FROM mails WHERE session_id=$1 ORDER BY created_at DESC LIMIT 1`,
sessionID).Scan(&mailID); err != nil {
t.Fatal(err)
}
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO relayed_mails (agent_name, relay_key, mail_id, kind)
VALUES ($1, $2, $3, 'summary')`, from, "test-key-"+mailID, mailID); err != nil {
t.Fatal(err)
}
}
func TestAgentPingPongSkipsRelayMails(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "回路", "")
if err != nil {
t.Fatal(err)
}
// 3 封模型主动互发 + 3 封插件代劳(relay)+ 2 封模型主动互发
seedPingPong(t, sid, "pi", "dsh")
seedPingPong(t, sid, "dsh", "pi")
seedPingPong(t, sid, "pi", "dsh")
seedPingPongRelay(t, sid, "homeagent", "dsh") // 退信 1
seedPingPongRelay(t, sid, "homeagent", "dsh") // 退信 2
seedPingPongRelay(t, sid, "homeagent", "dsh") // 退信 3
seedPingPong(t, sid, "dsh", "pi")
seedPingPong(t, sid, "pi", "dsh")
// 缺陷版本会数出 8(3+3+2)⇒ 触发上限;修复后 relay 不计数 ⇒ 5。
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 5 {
t.Fatalf("relay 邮件不应计入 Agent 互发数:应 5,实际 %d(err=%v)", n, err)
}
}
func TestAgentPingPongStillBreaksOnHumanAfterRelay(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "human", "回路", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('jianf', 'jianf', 'x', 'admin')`); err != nil {
t.Fatal(err)
}
// relay 群里夹一封人类参与的信 ⇒ 从它往后数
seedPingPongRelay(t, sid, "homeagent", "dsh")
seedPingPongRelay(t, sid, "homeagent", "dsh")
seedPingPong(t, sid, "jianf", "pi") // 人类发
seedPingPong(t, sid, "pi", "dsh")
if n, err := CountTrailingAgentPingPong(ctx, sid); err != nil || n != 1 {
t.Fatalf("人类参与仍应打断连续性(relay 在更早处):应 1,实际 %d(err=%v)", n, err)
}
}