fix(回路): Agent↔Agent 加 2h 冷静期;冷却期内 relay 不自动重投递
缺口:maxAgentPingPong=8 撞闸后**计数永不回落** —— 只有人类插话才归零。 旧文案把出路指向「请由人类插一句话」,而那条线索上常常**根本没有人类** (2026-10-01 报告:agent 一封都发不出,且那条线索无人在场)。 修法(两条要求分别落地): ① 2h 恢复机制:撞闸即写入 session_agent_locks(落库,内存态一重启就"恢复", 且多副本各算各的);到期自动放行,人类插话立刻解锁(优先于到期)。 ② 锁定期间的邮件不自动重投递:冷却期内 relay 直接 403 丢弃 —— **不排队、不占幂等键、不入库**。排队会在 2h 后一次性灌回去, 那等于把刚压住的回路换个更糟的形状放出来。 实测踩到的三个坑(都被判据抓住): - `VALUES ($1,$2,$2,...)` 让 until_at 复用 locked_at 的 $2 ⇒ 锁诞生即过期 - driver 以 UTC 扫回 DATETIME,而 time.Now() 是本地(HKT+8) ⇒ 差 8h > 2h 的一半 ⇒ 锁形同虚设 - 只查**收件方**是否人类,漏了**发件方** —— 而「人类插话解锁」的常态就是 人回信、收件方仍是 Agent ⇒ 人插话反被自己写的闸 403 拦下,那条出路根本不存在 - SQLite 没有 GREATEST;在 DATETIME 上按**字符串**比大小 ⇒ CASE 也会错。 改为「已有锁一律不碰 until_at」。 判据:repo 6 格(含"读失败不得读成未锁定"—— 最初 0 格能抓,变异测试补的) + handler 4 格。三个变异全部经得起(且每��都先确认变异编译通过再数红格 —— 本轮多次 grep 得 0 实际是 build failed,测试压根没跑)。
This commit is contained in:
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
@ -425,18 +426,79 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
if hops, hErr := repo.CountTrailingAgentPingPong(r.Context(), sessionID); hErr == nil {
|
||||
// 收件方是人类时不算回路(人类在回路里,正是我们要的"有人决策")。
|
||||
isHuman, _ := repo.IsHumanUser(r.Context(), to.Name)
|
||||
|
||||
// ★★ 2026-10-01:**发件方**是人类时同样不算(这是原实现漏掉的一边)。
|
||||
//
|
||||
// 原代码只查 `to.Name`。而"人类插一句话解锁"这个动作,
|
||||
// **发件方是人类**才是它的常态 —— 人是在这条线索里回信,
|
||||
// 收件方仍是 Agent。只看收件方 ⇒ 人插话反而被锁拦下(实测 403),
|
||||
// 于是"人类插话即可恢复"这条路是**不存在的**。
|
||||
//
|
||||
// 这条判据是当时逼出来的:`TestHumanPostClearsCooldownImmediately`
|
||||
// 一上来就红,报错文案还是我自己写的那句"请由人类插一句话"。
|
||||
fromHuman, _ := repo.IsHumanUser(r.Context(), agentName)
|
||||
humanInvolved := isHuman || fromHuman
|
||||
|
||||
// ★ 人类插话立刻清锁(优先于到期)。
|
||||
//
|
||||
// 旧文案只教「请由人类插一句话(计数即归零)」,而那条线索上
|
||||
// 往往**根本没有人类** —— 于是那一侧永久发不出信。
|
||||
// 现在有两条出路:人来插话(立刻),或冷静期到期(2h 自动)。
|
||||
if humanInvolved {
|
||||
_, _ = repo.SessionLockTouch(r.Context(), sessionID)
|
||||
}
|
||||
|
||||
// ★ 冷静期内:**Agent 的自主回信**一律拒(不重投、不排队)。
|
||||
//
|
||||
// 这道与上面那道计数闸是同一件事的两个阶段:计数闸负责"发现并开始锁",
|
||||
// 冷静期负责"锁住不放"。没有第二段的话,第一次撞闸后靠"计数 ≥8"
|
||||
// 继续拒 —— 形状一样,但**没有出路**:人不在就永远出不来。
|
||||
if !humanInvolved && relay == "" {
|
||||
if lock, locked, _ := repo.SessionLockOf(r.Context(), sessionID); locked {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话因 Agent↔Agent 无人决策回信已进入冷静期,还剩约 %s"+
|
||||
"(上一次连续 %d 封)。到时自动恢复;"+
|
||||
"若要立刻恢复,请由人类在会话里插一句话。",
|
||||
lock.Remaining.Round(time.Minute), lock.Hops))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !isHuman && hops >= repo.MaxAgentPingPong() && relay == "" {
|
||||
// 开始冷静期(不延长已有的)。落库以便重启后仍生效。
|
||||
_, _ = repo.SetSessionLock(r.Context(), sessionID, hops, "agent-ping-pong")
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话已连续 %d 封 Agent 之间互相回信、其中没有任何人类参与(上限 %d)。"+
|
||||
"这通常意味着两个 Agent 在互相确认而无人决策 —— 生产上实测过一天 175 封、"+
|
||||
"正文 612KB 却没有任何工作产出。请由人类在会话里插一句话(计数即归零)。"+
|
||||
"(本模型未计入插件代劳的转发/退信 —— 那类邮件归 maxRelayHops 管。)",
|
||||
hops, repo.MaxAgentPingPong()))
|
||||
"正文 612KB 却没有任何工作产出。\n"+
|
||||
"已进入 %s 冷静期,到时自动恢复;"+
|
||||
"若要立刻恢复,请由人类在会话里插一句话(立即解锁)。"+
|
||||
"(本闸不计入插件代劳的转发/退信 —— 那类邮件归 maxRelayHops 管。)",
|
||||
hops, repo.MaxAgentPingPong(), repo.AgentLockDuration))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if relay != "" {
|
||||
// ★ 2026-10-01:冷却期内**不自动重投递**(用户要求)。
|
||||
//
|
||||
// 形状上这道闸与下面那道 relay 上限一样是 403,但意图不同:
|
||||
// 下面那道拦的是"太多",这道拦的是"正在冷静期"。
|
||||
//
|
||||
// 为什么必须在这里挡:lock 的触发方是**模型自主回信**,被锁的却可能是
|
||||
// relay 邮件。两个 Agent 在冷却期里互相自动转发时,
|
||||
// 若这里放行 → 每封都唤醒对端 → 延迟投递量随时间堆积,
|
||||
// 2 小时后一次性灌回去 —— 那等于把刚压住的回路又放出来一遍,
|
||||
// 只是换了个更糟的形状。
|
||||
if lock, locked, _ := repo.SessionLockOf(r.Context(), sessionID); locked {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话处于 Agent↔Agent 冷静期(约剩 %s),**本次自动转发已丢弃**"+
|
||||
"(不会排队、到时不会补投)。"+
|
||||
"若这封确实需要送达,请由模型在恢复后主动调 send_mail,或由人类插一句话立即解锁。",
|
||||
lock.Remaining.Round(time.Minute)))
|
||||
return
|
||||
}
|
||||
|
||||
// 硬上限:一条会话里**连续**的 relay 邮件不得超过上限。
|
||||
//
|
||||
// 与预算无关的第二道防线:预算给得大(比如 200)时,两个 Agent 仍能
|
||||
|
||||
Reference in New Issue
Block a user