feat(permission): 待办带上失效时刻;越窗的决策不再假装成功

# 起因:一次端到端验证暴露的静默缺口

建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**

追下去是三件事叠在一起:

1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
   与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
   工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
   迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。

于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。

# 设计:邮件上给「时刻」,不给「是否失效」的布尔值

服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。

关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。

`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。

# 改动

- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
  `PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
  决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
  5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
  permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
  只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
  讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
  意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
  批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。

# 验证

- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
  - `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
  - 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
  - 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
    等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
This commit is contained in:
2026-09-11 22:02:25 +08:00
parent d50c55da4f
commit bbddee26b9
12 changed files with 501 additions and 6 deletions

View File

@ -2,8 +2,10 @@ package handler
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
@ -367,10 +369,44 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) {
"status": "active",
})
JSON(w, http.StatusOK, map[string]string{
// 已越过等待窗口的决策必须当场说清「这次批准不会恢复原调用」。
//
// 实测2026-09-11pi 桥等不到决策时,会在回合超时(默认 10 分钟)拆掉
// worker 与它的决策路由表;此后再来的决策只会作为**通知**投递给 Agent
// 不会恢复当时那次工具调用(该轮已经结束了)。
//
// 而接口原本照旧回 200 {"status":"decided"} —— 人在界面上看到批准成功,
// 实际那件事什么都没发生。这正是 I-5 要消灭的「静默成功」。
// 具体组装见 decideResponse。
JSON(w, http.StatusOK, decideResponse(perm.CreatedAt, time.Now(), decisionMailID.String()))
}
// decideResponse 组装权限决策的响应体。
//
// 抽成纯函数是为了可测:那条「越过等待窗口」的分支只有等满窗口才会走到,
// 不能只靠人工点一遍;而它正是「人看到批准成功、实际什么都没发生」的根源。
//
// 越窗时加 expired + warning 而**不**改 HTTP 状态码:决策本身仍是人的真实意愿、
// 仍然有效(桥会把它当通知投给 AgentAgent 重起一轮),所以不能拒掉;
// 但必须把发生了什么讲明白。
//
// 这里用布尔值而不是让客户端自己比:响应本身就是「此刻」的一次性快照,
// 不像邮件那样会被缓存反复展示。(邮件上给的则是失效**时刻**,见 models.Mail。
func decideResponse(createdAt, now time.Time, decisionMailID string) map[string]any {
resp := map[string]any{
"status": "decided",
"decision_mail_id": decisionMailID.String(),
})
"decision_mail_id": decisionMailID,
}
if deadline := models.PermissionDeadline(createdAt); now.After(deadline) {
resp["expired"] = true
resp["expires_at"] = deadline
resp["warning"] = fmt.Sprintf(
"该请求已超过等待窗口(%.0f 分钟),发起它的 Agent 很可能已不再阻塞等待。"+
"决策已记录并会投递给它,但不会恢复当时那次工具调用 —— "+
"它会把这次决策当作一条通知,重新起一轮。",
models.PermissionWaitWindow.Minutes())
}
return resp
}
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的

View File

@ -0,0 +1,92 @@
package handler
import (
"fmt"
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/models"
)
// 越窗的决策必须如实告知「这次批准不会恢复原调用」。
//
// # 为什么需要这条测试
//
// 2026-09-11 端到端实测:人在界面上批准了一条待办,接口回 200但那件事什么都没
// 发生 —— 提出询问的桥在回合超时(默认 10 分钟)后已拆掉 worker 与决策路由表,
// 此后的决策只会作为通知投递给 Agent。接口当时照旧回 {"status":"decided"}
// 人无从知道自己的批准落到了一个已经没人等的请求上。
//
// 这条分支只有等满等待窗口才会走到,属于「人工点一遍很难覆盖」的路径,
// 所以把它抽成纯函数并在这里钉住两侧:窗口内不许误报,越窗必须报。
func TestDecideResponseFlagsExpiredRequest(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 30, 0, 0, time.UTC)
const mailID = "decision-mail-id"
t.Run("窗口内:不报过期,且不出现 warning", func(t *testing.T) {
createdAt := now.Add(-2 * time.Minute)
resp := decideResponse(createdAt, now, mailID)
if resp["status"] != "decided" {
t.Errorf("status = %v期望 decided", resp["status"])
}
if resp["decision_mail_id"] != mailID {
t.Errorf("decision_mail_id = %v期望 %s", resp["decision_mail_id"], mailID)
}
if _, ok := resp["expired"]; ok {
t.Error("窗口内不该标 expired —— 误报会让人以为批准没生效,比不报更糟")
}
if _, ok := resp["warning"]; ok {
t.Error("窗口内不该带 warning")
}
})
t.Run("刚好到截止时刻:不算过期(边界取严格大于)", func(t *testing.T) {
createdAt := now.Add(-models.PermissionWaitWindow)
resp := decideResponse(createdAt, now, mailID)
if _, ok := resp["expired"]; ok {
t.Error("恰好等于截止时刻时不该算过期")
}
})
t.Run("越窗:必须标 expired 并附 warning且讲清『不会恢复原调用』", func(t *testing.T) {
createdAt := now.Add(-1 * time.Hour)
resp := decideResponse(createdAt, now, mailID)
if resp["expired"] != true {
t.Errorf("expired = %v期望 true", resp["expired"])
}
wantDeadline := models.PermissionDeadline(createdAt)
if got, ok := resp["expires_at"].(time.Time); !ok || !got.Equal(wantDeadline) {
t.Errorf("expires_at = %v期望 %v", resp["expires_at"], wantDeadline)
}
warning, _ := resp["warning"].(string)
if warning == "" {
t.Fatal("越窗必须带 warning —— 否则就是又一次静默成功")
}
// 必须说清两件事:决策已记录(所以不用重试)、不会恢复原调用(所以别等它)。
for _, want := range []string{"不会恢复", "通知"} {
if !strings.Contains(warning, want) {
t.Errorf("warning 里应含 %q实际 %q", want, warning)
}
}
// 时长要由常量推导,不能写死,否则改了窗口这句话就成了假话。
wantNum := fmt.Sprintf("%.0f", models.PermissionWaitWindow.Minutes())
if !strings.Contains(warning, wantNum) {
t.Errorf("warning 应提到等待窗口(%s 分钟),实际 %q", wantNum, warning)
}
})
}
// 等待窗口必须与插件的回合超时同量级:窗口若短于插件真实等待时间,
// 界面会把「其实还在等」的待办标成过期,同样是在说假话。
func TestPermissionWaitWindowIsSane(t *testing.T) {
if models.PermissionWaitWindow < 1*time.Minute {
t.Fatalf("等待窗口 %v 太短,会把仍在等待的待办误标为过期", models.PermissionWaitWindow)
}
if models.PermissionWaitWindow > 30*time.Minute {
t.Fatalf("等待窗口 %v 过长,界面几乎不会提示过期,等于没做", models.PermissionWaitWindow)
}
}