fix(网关): 占住幂等键后的早退路径没退键 —— 永久占键、重试永远 duplicate

## 缺陷

`ClaimRelay` 成功之后、邮件落库之前有若干条早退路径,只有**部分**在 return 前
调了 `ReleaseRelay`。漏掉的那些会把 (agent_name, relay_key) 永久占住:
之后同一上游消息的重试拿到 `ErrRelayDuplicate` ⇒ 返回 200 `duplicate_relay`
(文案是「该上游消息已转发过,本次调用未产生新邮件」),
而那条消息**从未发出去** —— 响应在陈述一件没发生的事。

野外实例(现库仍在,`relayed_mails` 唯一一行 `mail_id IS NULL`):

    agent_name = zcode
    relay_key  = no-such-session-0000:toolu-nohuman-1789193173578
    created_at = 2026-09-12 06:06:13

它的 session 位不是 UUID,于是 `RequestPermission` 走到
`uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)直接 return,
没有退键。该行从 09-12 留到现在。

`mail.go` 同类:`ClaimRelay`(:396)之后 `ConsumeSessionBudget` 返回
非「额度耗尽」错误时(:433)也没退键。

## 修法

不在各 return 前逐个补 `ReleaseRelay` —— 那正是缺陷的成因(漏一处就是一个静默的
永久占键,而漏掉的那处不会有任何报错)。改为占键成功后立刻 `defer` 一次释放,
让"还键"成为该作用域的唯一出口不变量。

成功路径不需要额外开关:`ReleaseRelay` 的 WHERE 含 `mail_id IS NULL`,
`BindRelayMail` 一旦成功该行就不再匹配,defer 自然什么都不做。

## 判据(两条独立用例,缺一即放过一类坏实现)

`TestRequestPermissionReleasesRelayKeyOnEarlyExit` —— 失败路径:早退后同一个
relay_key 必须还能再次占用(键被还回来了)。

`TestRequestPermissionClaimsRelayKeyOnSuccess` —— 成功路径:同一询问重复投递
必须被幂等键挡住,且键确实绑定到了第一封邮件上。

两条必须分开:早退时"正确实现"与"把 ClaimRelay 换成空操作"的坏实现在表里
**观测等价**(都留下一个空键),单条用例无法同时钉住"该退的时候退"和
"该占的时候占"。已逐个变异验证:

    基线(缺陷代码)  → 早退用例红、成功用例绿
    早退处补 Release  → 两条全绿
    ClaimRelay 置空   → 成功用例红(早退用例仍绿,符合预期)
    BindRelayMail 去掉 → 两条全红

`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
This commit is contained in:
2026-09-25 05:41:57 +08:00
parent 54d641e1c5
commit 34a15dc09c
3 changed files with 173 additions and 0 deletions

View File

@ -406,6 +406,19 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
// 键已占住,此后**每一条**早退路径都必须把它还回去,否则这条
// (agent_name, relay_key) 被永久占用:上游那条消息再也转不出来,
// 重试只会拿到 duplicate_relay(复用「已转发过」那条响应)。
//
// 用 defer 而不是在各 return 前逐个补:这条路上有多处早退
// (预算耗尽、预算检查出错、建邮件失败……),逐个补漏掉一处就是一个
// 静默的永久占键。与 permission.go 同因同法。
//
// 成功路径不需要额外开关:ReleaseRelay 只删 `mail_id IS NULL` 的占位行,
// BindRelayMail 一旦成功,这行就不再匹配,defer 自然什么都不做。
defer func() {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}()
}
if relayFree {

View File

@ -102,6 +102,22 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
// 键已占住,此后**每一条**早退路径都必须把它还回去,否则这条
// (agent_name, relay_key) 被永久占用:之后的重试只会拿到
// duplicate_relay(复用「已转发过」那条响应),而这次询问其实从未发出。
//
// 用 defer 而不是在各 return 前逐个补 ReleaseRelay:这条路上有多处早退
// (session_id 不是 UUID、建会话失败……),逐个补漏掉一处就是一个静默的
// 永久占键。野外已有一例:relay_key
// `no-such-session-0000:toolu-nohuman-1789193173578` 的 session 位不是
// UUID,走到 `Invalid session_id` 那条 return 时没退键,2026-09-12 起
// 一直留在表里(mail_id 为空)。
//
// 成功路径不需要额外开关:ReleaseRelay 只删 `mail_id IS NULL` 的占位行,
// 而下面 BindRelayMail 一旦成功,这行就不再匹配,defer 自然什么都不做。
defer func() {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}()
}
// 确定 session

View File

@ -0,0 +1,144 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
)
// 占住幂等键之后,**任何**一条早退路径都必须把键还回去;同时幂等性本身不能坏掉。
//
// 背景(真实数据):现库里 `relayed_mails` 有一行孤儿键 ——
//
// agent_name = zcode
// relay_key = no-such-session-0000:toolu-nohuman-1789193173578
// mail_id = NULL (= 占位成功,但从未绑定到任何邮件)
// created_at = 2026-09-12 06:06:13
//
// 该键的 session 位不是 UUID,于是 `RequestPermission` 走到
// `uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)时直接 return,
// **没有调用 ReleaseRelay**。键因此永久留在表里:之后任何用同一个
// (agent_name, relay_key) 的重试都会拿到 `ErrRelayDuplicate`(→ 200
// duplicate_relay),而那次询问其实**从未发出去**。
//
// 为什么必须是两个独立的测试:这两件事在**早退路径**上观测等价。
// 早退时正确实现与"把 ClaimRelay 换成空操作"的坏实现**都**在表里留下一个空键,
// 所以任何单条用例都无法同时钉住"该退的时候退"和"该占的时候占"。
// 前者只能在**失败路径**上观测(键本该被还回来),
// 后者只能在**成功路径**上观测(键本该被占住)。
// 失败路径:早退之后同一个 relay_key 必须还能再次被占用(键被还回来了)。
func TestRequestPermissionReleasesRelayKeyOnEarlyExit(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
t.Fatalf("注册 pi: %v", err)
}
// 非 UUID 的 session 位:这正是野外那行孤儿键的形态,也是走到早退的入口。
const relayKey = "no-such-session-0000:toolu-nohuman-1789193173578"
resp := requestPermissionRaw(t, map[string]any{
"session_id": "no-such-session-0000",
"to": "human",
"question": "是否允许执行 bash?",
"options": []string{"同意", "拒绝"},
"relay_key": relayKey,
})
// 前提:请求确实失败(否则这个用例没验到早退那条路)。
if resp.Code != http.StatusBadRequest {
t.Fatalf("非 UUID 的 session_id 必须 400,实际 HTTP %d: %s", resp.Code, resp.Body.String())
}
// 断言:键被还回来了 —— 同一个键必须能再次占用。
if err := repo.ClaimRelay(ctx, "pi", relayKey, "permission"); err != nil {
t.Fatalf("早退路径没把幂等键还回去:再次占用同一个键被拒(%v)—— "+
"该 (agent, relay_key) 已被永久占用,之后的重试只会拿到 duplicate_relay,"+
"而那次询问从未发出", err)
}
}
// 成功路径:幂等键必须真的被占住 —— 同一条询问重复投递只能生成一封邮件。
//
// 这条与上一条是**互补**的,不是重复:把 `ClaimRelay` 换成空操作会让上一条全绿
// (键从未被占 ⇒ "还能再占"天然成立),只有这条会红。
func TestRequestPermissionClaimsRelayKeyOnSuccess(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
t.Fatalf("注册 pi: %v", err)
}
mustPermissionUser(t, "owner")
sid, err := repo.CreateSession(ctx, nil, "pi", "权限幂等", "/tmp")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if err := repo.SetSessionOwner(ctx, sid, permissionUserID(t, "owner")); err != nil {
t.Fatalf("设置 owner: %v", err)
}
if _, err := repo.CreateMail(ctx, sid, nil, "owner", "", "pi", "/tmp",
"任务", "请执行", nil); err != nil {
t.Fatalf("建线索邮件: %v", err)
}
const relayKey = "sess-1:toolu-repeat-1"
body := map[string]any{
"session_id": sid.String(),
"to": "owner",
"question": "是否允许执行 bash?",
"options": []string{"同意", "拒绝"},
"relay_key": relayKey,
}
first := requestPermissionRaw(t, body)
if first.Code != http.StatusOK {
t.Fatalf("首次询问必须受理,HTTP %d: %s", first.Code, first.Body.String())
}
// 第二次同一 relay_key:幂等要求它**不产生新邮件**。
second := requestPermissionRaw(t, body)
if second.Code != http.StatusOK {
t.Fatalf("重复投递应幂等返回 200,HTTP %d: %s", second.Code, second.Body.String())
}
var got map[string]any
if err := json.Unmarshal(second.Body.Bytes(), &got); err != nil {
t.Fatalf("解析响应: %v", err)
}
if got["status"] != "duplicate_relay" {
t.Fatalf("重复投递必须被幂等键挡住(status=duplicate_relay),实际 %v —— "+
"幂等键没生效,SSE 重放与插件重试会给同一次询问发多封邮件", got["status"])
}
// 并且库里那条键确实绑到了第一封邮件上(不是留在空占位状态)。
var bound string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_id FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2`,
"pi", relayKey).Scan(&bound); err != nil {
t.Fatalf("查幂等键绑定: %v", err)
}
if bound == "" {
t.Fatal("成功路径没有把幂等键绑定到邮件上(mail_id 为空)—— " +
"该键会被 ReleaseRelay 当成占位残留删掉,幂等随之失效")
}
}
// requestPermissionRaw 发一次 /permission/request,body 由调用方给定。
func requestPermissionRaw(t *testing.T, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
payload, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/permission/request", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), middleware.AgentNameKey, "pi")
resp := httptest.NewRecorder()
RequestPermission(resp, req.WithContext(ctx))
return resp
}