Files
MailUI4Agents/server/internal/repo/agentlock.go
JianFeeeee a8d6811cd7 style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt

`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `//   x` 规范成 `//\tx`。
功能零变化。

## 一处实质:init.sql 补 parent_session_id

`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
 TEXT REFERENCES sessions(session_id)`

远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。

- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)

## 新增 handler/ratelimit_test.go(162 行)

速率限制的判据。此前这一块没有独立判据。

## 验证(本机实跑,非推断)

    go build ./...                              通过
    go vet ./internal/{handler,repo,db,sse}/   通过
    go test -count=1(handler/repo/db/sse/blob)
      handler  41.3s  ok
      repo    101.9s  ok
      db       1.1s  ok
      sse      0.01s ok
      blob     0.08s ok

`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
2026-10-05 09:26:59 +08:00

140 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
}