Files
MailUI4Agents/server/internal/repo/agentlock.go
JianFeeeee 4d8165fde9 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,测试压根没跑)。
2026-10-01 19:41:06 +08:00

139 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// Agent↔Agent 回路的**锁定期**(2026-10-01)
//
// # 缺口
//
// `maxAgentPingPong = 8` 拦下"两个 Agent 互相确认而无人决策"之后,
// **计数永不回落**:只有人类插话才归零。2026-10-01 的报告就是那个形状——
//
// 本会话已连续 8 封 Agent 之间互相回信、其中没有任何人类参与(上限 8)。
// …请由人类在会话里插一句话(计数即归零)。
//
// 而这条线索上**没有人类**(Agent 之间自己开的会话,或人已经不在),
// 于是那一侧**永久**发不出信,且文案把出路指向一个不存在的操作。
//
// # 修法:给一个不依赖人的出口
//
// 到 `until_at` 自动放行。语义是「冷静期」而不是「永久封禁」:
// 8 封无决策的互发多半是两个 Agent 在空转,2 小时足以让它们停下;
// 而真的需要继续时,2 小时后自己就能走,或由人插一句话立刻恢复。
//
// # 为什么落库
//
// 放内存会有两个问题:重启即"恢复"(而锁定期的意义正是别短时间内再炸一轮),
// 以及多副本部署时各算各的。`sessions` 表已有,改动落在它旁边而不是内存态。
//
// # 人类插话仍然立刻归零
//
// `SessionLockTouch`:人来信即清锁。这条优先级高于到期 ——
// 人参与了就不该再等冷静期。
const AgentLockDuration = 2 * time.Hour
// ErrSessionLocked 表示该会话处于 Agent 回路冷静期,Agent 的自主回信被拒。
var ErrSessionLocked = errors.New("session is in agent-loop cooldown")
// SessionLock 是一次锁定的状态(供错误文案与测试用)。
type SessionLock struct {
Until time.Time
Hops int
// Remaining 是剩余锁定时长;<= 0 表示已到期或未锁定。
Remaining time.Duration
}
// Active 报告该锁定是否仍在生效。
func (l SessionLock) Active() bool { return l.Until.After(time.Now()) }
// SessionLockOf 返回该会话当前的锁定状态;未锁定返回零值 + ok=false。
//
// 过期行**顺手删掉**:判据是 `until_at`,而清理只是回收空间 ——
// 留着它会让"是否锁定"的读数有两个答案(行在 / 行不在),而只有一个是真的。
func SessionLockOf(ctx context.Context, sessionID uuid.UUID) (SessionLock, bool, error) {
var until time.Time
var hops int
err := db.DB.QueryRowContext(ctx,
`SELECT until_at, hops FROM session_agent_locks WHERE session_id = $1`, sessionID,
).Scan(&until, &hops)
// ⚠ 必须区分「查不到」与「读失败」。
//
// 我第一版把两者合并成 `return {}, false, nil`,于是 Scan 失败
// 被读成"未锁定" ⇒ **锁完全失效,且看起来一切正常**。
// 这正是我自己写在上面的那段警告,只是没做到。
// 而把读失败当安全,正是安全系统最不该犯的错。
if errors.Is(err, sql.ErrNoRows) {
return SessionLock{}, false, nil // 真的没锁:正常路径
}
if err != nil {
// 读不出来 = **不知道**。不能当成"没锁"——那等于静默拆掉防护。
// 调用方按"仍在锁"处理(宁可挡住,不可放行)。
return SessionLock{}, true, fmt.Errorf("read session lock: %w", err)
}
// ⚠ 时区:本库 DATETIME 列由 driver 以 **UTC** 扫出(实测读回
// time.Date(..., time.UTC)),而 time.Now() 是本地时区(HKT,+8)。
// 直接比会把「还剩 2 小时」算成「已过期 8 小时」⇒ 锁一设就失效。
// 一律用 UTC 比较,与 driver 对齐。
if !until.After(time.Now().UTC()) {
_, _ = db.DB.ExecContext(ctx,
`DELETE FROM session_agent_locks WHERE session_id = $1`, sessionID)
return SessionLock{}, false, nil
}
return SessionLock{Until: until, Hops: hops, Remaining: time.Until(until)}, true, nil
}
// SetSessionLock 锁住该会话:已开始一次 `AgentLockDuration` 的冷静期。
//
// 已有锁定期**不延长**(`until_at` 只会往前推靠 `GREATEST`,不是每次调用都 +
// 2h)。理由:反复触发时"每次 +2h"等于永不解锁 ——
// 一个每 30 分钟试一次的 Agent 会把会话永久锁住,而那正是我们要避免的。
func SetSessionLock(ctx context.Context, sessionID uuid.UUID, hops int, reason string) (SessionLock, error) {
// ⚠ 时区:写入也用 UTC。本地时间(HKT)与 driver 读回的 UTC 混用时,
// 差 8 小时会让 2 小时的锁看起来像「已过期 6 小时」⇒ 锁形同虚设。
now := time.Now().UTC()
until := now.Add(AgentLockDuration)
// ⚠ 已有锁定期**一律不延长**(真的不碰 until_at)。
//
// 两个坑都踩过:
// ① SQLite 没有 GREATEST(实测 `no such function: GREATEST`),
// 而本项目两种方言都要支持(migrate.go 明确要求两处同改)。
// ② 改用 CASE 比大小也不行 —— SQLite 在 DATETIME 上按**字符串**比,
// 而新算出来的 until 确实更晚 ⇒ 每次触发都往后推 1 秒。
//
// 语义上「不延长」就应该是「不碰 until_at」:行存在即代表它生效中
// (过期的行 SessionLockOf 读的时候已经顺手删了)。
_, err := db.DB.ExecContext(ctx, `
INSERT INTO session_agent_locks (session_id, locked_at, until_at, hops, reason)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (session_id) DO UPDATE
SET hops = EXCLUDED.hops,
reason = EXCLUDED.reason`,
sessionID, now, until, hops, reason)
if err != nil {
return SessionLock{}, err
}
return SessionLock{Until: until, Hops: hops, Remaining: AgentLockDuration}, nil
}
// SessionLockTouch 清掉该会话的锁定 —— **人类插话即立刻恢复**。
//
// 返回是否真的清掉了一条(false = 本来就没锁)。
func SessionLockTouch(ctx context.Context, sessionID uuid.UUID) (bool, error) {
tag, err := db.DB.ExecContext(ctx,
`DELETE FROM session_agent_locks WHERE session_id = $1`, sessionID)
if err != nil {
return false, err
}
n, _ := tag.RowsAffected()
return n > 0, nil
}