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

@ -408,17 +408,25 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
# 触发时做什么
**拒绝并说清怎么办**,不静默限流 —— 与 hop 那道同样的处理。
人类插一句话或模型说明为何必须继续,都能立刻恢复。
人类插一句话即可立刻恢复(计数按"尾部连续无人类"扫,人类一来就归零)。
# ★ 与 relay 的关系(2026-09-30 修,dsh)
本闸对 relay 邮件**放行**(条件里的 `relay == ""`)。原因见
`repo.CountTrailingAgentPingPong` 的函数头注释:插件代劳的退信若被
本闸拦下,发件人只会收到"模型未产生回复"(桥的 sendFailureReply
也发不出去),真因被吞掉,且计数永不回落 ⇒ 会话死锁。
relay 类邮件由下一道 maxRelayHops=5 管,阈值更严,不会因此失守。
*/
if hops, hErr := repo.CountTrailingAgentPingPong(r.Context(), sessionID); hErr == nil {
// 收件方是人类时不算回路(人类在回路里,正是我们要的"有人决策")。
isHuman, _ := repo.IsHumanUser(r.Context(), to.Name)
if !isHuman && hops >= repo.MaxAgentPingPong() {
if !isHuman && hops >= repo.MaxAgentPingPong() && relay == "" {
Error(w, http.StatusForbidden, fmt.Sprintf(
"本会话已连续 %d 封 Agent 之间互相回信、其中没有任何人类参与(上限 %d)。"+
"这通常意味着两个 Agent 在互相确认而无人决策 —— 生产上实测过一天 175 封、"+
"正文 612KB 却没有任何工作产出。请由人类在会话里插一句话(计数即归零),"+
"或说明为何这轮必须继续。",
"正文 612KB 却没有任何工作产出。请由人类在会话里插一句话(计数即归零)。"+
"(本模型未计入插件代劳的转发/退信 —— 那类邮件归 maxRelayHops 管。)",
hops, repo.MaxAgentPingPong()))
return
}