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:
139
server/internal/repo/agentlock.go
Normal file
139
server/internal/repo/agentlock.go
Normal file
@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
222
server/internal/repo/agentlock_test.go
Normal file
222
server/internal/repo/agentlock_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
/*
|
||||
Agent↔Agent 回路的**冷静期**(2026-10-01)
|
||||
|
||||
# 缺口
|
||||
|
||||
`maxAgentPingPong = 8` 撞闸之后**计数永不回落** —— 只有人类插话才归零。
|
||||
旧文案把出路指向"请由人类插一句话",而那条线索上**常常根本没有人类**,
|
||||
于是那一侧永久发不出信(2026-10-01 报告:agent 一封都发不出)。
|
||||
|
||||
# 这几条钉的是"有出路",不是"有锁"
|
||||
|
||||
`TestSessionLockBlocksThenRecovers` —— 锁住 / 到期放行
|
||||
`TestSessionLockNotExtendedByRetry` —— 重试不延长(否则永不恢复)
|
||||
`TestHumanTouchClearsLock` —— 人插话立刻解锁
|
||||
`TestExpiredLockRowIsCleaned` —— 过期行清理(不留两个答案)
|
||||
`TestSessionLockSurvivesReconnect` —— 落库而非放内存
|
||||
*/
|
||||
|
||||
func TestSessionLockBlocksThenRecovers(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, _ := CreateSession(ctx, nil, "human", "冷静期", "")
|
||||
|
||||
// 初始:未锁定
|
||||
if _, locked, err := SessionLockOf(ctx, sid); err != nil || locked {
|
||||
t.Fatalf("初始应为未锁定:locked=%v err=%v", locked, err)
|
||||
}
|
||||
|
||||
lock, err := SetSessionLock(ctx, sid, 8, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lock.Active() {
|
||||
t.Fatal("刚设的锁应当生效")
|
||||
}
|
||||
|
||||
// 在锁:Agent 的自主回信被拒
|
||||
if _, locked, _ := SessionLockOf(ctx, sid); !locked {
|
||||
t.Fatal("★ 锁定期内必须拦住(这正是「一封都发不出」那个缺陷)")
|
||||
}
|
||||
|
||||
// 到期:自动恢复 —— **不需要任何人介入**
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE session_agent_locks SET until_at = ? WHERE session_id = ?`,
|
||||
time.Now().Add(-time.Minute), sid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, locked, _ := SessionLockOf(ctx, sid); locked {
|
||||
t.Fatal("★ 到期后必须自动放行 —— 恢复不能依赖人类在场")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLockNotExtendedByRetry(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, _ := CreateSession(ctx, nil, "human", "重试", "")
|
||||
|
||||
if _, err := SetSessionLock(ctx, sid, 8, "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, _, _ := SessionLockOf(ctx, sid)
|
||||
|
||||
// 一个每 30 分钟试一次的 Agent:若每次都 +2h ⇒ 永不解锁
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
if _, err := SetSessionLock(ctx, sid, 9, "retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, _ := SessionLockOf(ctx, sid)
|
||||
|
||||
if second.Until.After(first.Until) {
|
||||
t.Fatalf("★ 重试不得延长锁定期(否则反复触发=永不解锁):%s → %s",
|
||||
first.Until, second.Until)
|
||||
}
|
||||
if second.Hops != 9 {
|
||||
t.Fatalf("hops 应更新为最新值,实际 %d", second.Hops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanTouchClearsLock(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, _ := CreateSession(ctx, nil, "human", "人类插话", "")
|
||||
|
||||
if _, err := SetSessionLock(ctx, sid, 8, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleared, err := SessionLockTouch(ctx, sid)
|
||||
if err != nil || !cleared {
|
||||
t.Fatalf("人类插话应立刻清锁:cleared=%v err=%v", cleared, err)
|
||||
}
|
||||
if _, locked, _ := SessionLockOf(ctx, sid); locked {
|
||||
t.Fatal("★ 人参与了就不该再等冷静期")
|
||||
}
|
||||
|
||||
// 再插一次:本来就没锁 ⇒ 不报错,只是"没清掉什么"
|
||||
if cleared, err := SessionLockTouch(ctx, sid); err != nil || cleared {
|
||||
t.Fatalf("未锁定时重复 touch 应为 no-op:cleared=%v err=%v", cleared, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredLockRowIsCleaned(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, _ := CreateSession(ctx, nil, "human", "过期", "")
|
||||
|
||||
if _, err := SetSessionLock(ctx, sid, 8, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE session_agent_locks SET until_at = ? WHERE session_id = ?`,
|
||||
time.Now().Add(-time.Hour), sid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, locked, _ := SessionLockOf(ctx, sid); locked {
|
||||
t.Fatal("过期行应视为未锁定")
|
||||
}
|
||||
|
||||
// 读数只有一个答案:行也必须被回收
|
||||
var n int
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM session_agent_locks WHERE session_id = ?`, sid).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("★ 过期行应顺手清理(留两个答案会让「是否锁定」不可判):剩 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLockSurvivesReconnect(t *testing.T) {
|
||||
// 落库而非放内存:内存态一重启就"恢复",而锁定期的意义正是
|
||||
// 别在短时间内又炸一轮;且多副本部署时各算各的。
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lock.db")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.Connect(ctx, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid, _ := CreateSession(ctx, nil, "human", "重启", "")
|
||||
if _, err := SetSessionLock(ctx, sid, 8, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close() // 模拟重启
|
||||
|
||||
if err := db.Connect(ctx, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
if _, locked, err := SessionLockOf(ctx, sid); err != nil || !locked {
|
||||
t.Fatalf("★ 重启后冷却期必须仍在(否则它只是个瞬时现象):locked=%v err=%v", locked, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认表真的建在两种方言的 schema 里(老库靠 migrate,新库靠 init.sql)。
|
||||
func TestSessionLockTableExistsOnSQLite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
var name string
|
||||
if err := db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='session_agent_locks'`).Scan(&name); err != nil {
|
||||
t.Fatalf("session_agent_locks 未建表:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
★ 读失败**不得**被读成"未锁定"(变异②补:原本 0 格能抓,漏网了)
|
||||
|
||||
# 为什么单独一格
|
||||
|
||||
我把 `err != nil` 合并进 `return {}, false, nil`(即「读不出来 = 没锁」)。
|
||||
上面五条判据**全都抓不住它** —— 它们都不制造读失败,只是各走各的正常路径。
|
||||
|
||||
而这个形状是本仓记录过的同一个错:「`getWorkspace` 拿不到时不带,让服务端报 400」
|
||||
背后的原则是**宁可报错、不可静默降级**;这里反过来:**读不出来却静默放行**。
|
||||
把"不知道"读成"安全",等于静默拆掉防护,且**没有任何症状**。
|
||||
|
||||
# 怎么钉
|
||||
|
||||
让读真的失败(表不存在),然后断言「**仍报在锁**」——
|
||||
若实现退化成 `return {}, false, nil`,locked 会变 false ⇒ 这格变红。
|
||||
*/
|
||||
func TestSessionLockReadFailureIsNotTreatedAsUnlocked(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, _ := CreateSession(ctx, nil, "human", "读失败", "")
|
||||
|
||||
// 制造一次真实的读失败:把表改名,Scan 就会报 no such table
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`ALTER TABLE session_agent_locks RENAME TO session_agent_locks_gone`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`ALTER TABLE session_agent_locks_gone RENAME TO session_agent_locks`)
|
||||
})
|
||||
|
||||
lock, locked, err := SessionLockOf(ctx, sid)
|
||||
if !locked {
|
||||
t.Fatalf("★ 读失败必须当「仍在锁」而非「未锁定」"+
|
||||
"(否则一次 DB 抖动就静默拆掉整个防护,且无任何症状):locked=%v lock=%+v err=%v",
|
||||
locked, lock, err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("读失败必须把错误往上带(调用方需要知道这是异常而不是「没锁」)")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = uuid.New
|
||||
Reference in New Issue
Block a user