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:
dsh
2026-09-30 17:08:49 +08:00
parent 60012ede3d
commit 974bf2a62f
3 changed files with 124 additions and 7 deletions

View File

@ -48,12 +48,33 @@ const maxAgentPingPong = 8
// CountTrailingAgentPingPong 数会话尾部**连续的、无人类参与**的 Agent↔Agent 邮件数。
//
// 返回值即「若本次再发一封,它会是第几跳」的前一个数。
//
// ★ relay 邮件**不计数**(2026-09-30 修,dsh):
//
// 本函数原样数了插件代劳的邮件,与设计意图(注释第 15~16 行「relay = 0」
// 的实测场景)相悖 —— 那次实测里回路全由**模型主动 send_mail** 构成,
// 所以设计时默认「这条路径上没有 relay 邮件」。但插件代劳的邮件
// (失败退信 sendFailureReply、权限询问转发、总结转发)也满足
// 「发件方 Agent + 收件方 Agent + 无人类」,一旦被计入,就会出现:
//
// ① 退信计入 8 连 ⇒ 上限被**插件自己**触发;
// ② 闸门在 send 入口、先于 relay 分支 ⇒ 插件代劳的退信同样被 403;
// ③ 退信发不出去 ⇒ 发件人只看到「模型未产生回复」,不知道真因;
// ④ 计数只增不减(每一次被拒的尝试都不产生新邮件,计数不动,
// 但每次模型真生成完回复再被拒,桥又发一封退信也进不来)⇒
// 会话进入**永久死锁**:模型想回信 → 403 → 退信也 403 →
// 发件人重发 → 模型再回 → 再 403,没有任何一方能解。
//
// relay 邮件有自己的计数器(CountTrailingRelayHops,阈值 5,更严),
// 把两类计数混在同一个闸里正是本缺陷的根因。
func CountTrailingAgentPingPong(ctx context.Context, sessionID uuid.UUID) (int, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human,
CASE WHEN r.mail_id IS NULL THEN 0 ELSE 1 END AS is_relay
FROM mails m
LEFT JOIN relayed_mails r ON r.mail_id = m.mail_id
WHERE m.session_id = $1
ORDER BY m.created_at DESC, m.mail_id DESC`, sessionID)
if err != nil {
@ -63,14 +84,19 @@ func CountTrailingAgentPingPong(ctx context.Context, sessionID uuid.UUID) (int,
n := 0
for rows.Next() {
var fromHuman, toHuman bool
if err := rows.Scan(&fromHuman, &toHuman); err != nil {
var fromHuman, toHuman, isRelay bool
if err := rows.Scan(&fromHuman, &toHuman, &isRelay); err != nil {
return 0, err
}
// 人类参与(发或收)即打断连续性 —— 与 maxRelayHops 同一个"连续"语义。
if fromHuman || toHuman {
break
}
// ★ relay(插件代劳)不计入:它有自己的更严阈值(maxRelayHops=5),
// 且绝不能反过来把模型主动回信的通道锁死(详见函数头注释 ①~④)。
if isRelay {
continue
}
n++
}
return n, rows.Err()