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,被同一道闸拦着,这条恢复路径不存在。
This commit is contained in:
@ -137,3 +137,86 @@ func TestAgentPingPongNotResetByAgents(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user