## 主体是 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)`,那种绿灯不算数。
140 lines
6.0 KiB
Go
140 lines
6.0 KiB
Go
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
|
||
}
|