From 974bf2a62fa249c3ae65429dca6f15fbd442781d Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 30 Sep 2026 17:08:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E2=98=85=20relay=20=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E8=AE=A1=E5=85=A5=20Agent=20=E4=BA=92?= =?UTF-8?q?=E5=8F=91=E4=B8=8A=E9=99=90=20+=20=E8=AF=A5=E9=97=B8=E6=94=BE?= =?UTF-8?q?=E8=A1=8C=20relay=20=E5=8F=91=E9=80=81=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=BF=AE=E3=80=8C=E9=80=80=E4=BF=A1=E6=8A=8A=E9=80=9A=E9=81=93?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E9=94=81=E6=AD=BB=E3=80=8D=E7=9A=84=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=AD=BB=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 症状(生产会话 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,被同一道闸拦着,这条恢复路径不存在。 --- server/internal/handler/mail.go | 16 +++-- server/internal/repo/agentloop.go | 32 +++++++++- server/internal/repo/agentloop_test.go | 83 ++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/server/internal/handler/mail.go b/server/internal/handler/mail.go index b7bef49..84e8ba6 100644 --- a/server/internal/handler/mail.go +++ b/server/internal/handler/mail.go @@ -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 } diff --git a/server/internal/repo/agentloop.go b/server/internal/repo/agentloop.go index 93c1fb4..5b3c84a 100644 --- a/server/internal/repo/agentloop.go +++ b/server/internal/repo/agentloop.go @@ -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() diff --git a/server/internal/repo/agentloop_test.go b/server/internal/repo/agentloop_test.go index e47b096..168ad44 100644 --- a/server/internal/repo/agentloop_test.go +++ b/server/internal/repo/agentloop_test.go @@ -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) + } +}