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:
141
server/internal/repo/permission_deadline_test.go
Normal file
141
server/internal/repo/permission_deadline_test.go
Normal file
@ -0,0 +1,141 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
// 待决权限的**失效时刻**必须在每条读路径上都透出。
|
||||
//
|
||||
// # 为什么值得钉一个测试
|
||||
//
|
||||
// 2026-09-11 端到端实测:人在界面上批准了一条待办,接口回 200,但那件事什么都没
|
||||
// 发生。原因是提出询问的桥在回合超时(默认 10 分钟)后拆掉了 worker 与它的决策
|
||||
// 路由表,此后到达的决策只会作为通知投递给 Agent,不恢复当时那次工具调用。
|
||||
//
|
||||
// 而数据库里那条 permission_requests 的 result 永远是 NULL —— 界面据此把它渲染成
|
||||
// 「待决策」,于是一条早就没人等的待办会永远挂在授权页上,人点了也白点。
|
||||
//
|
||||
// 服务端不必替插件宣布裁决(那是否还在等是插件进程内的状态),但它必须给出
|
||||
// **这条待办什么时候算过期**,否则任何客户端都无从显示。这个测试锁的就是这件事:
|
||||
// 不是「某个字段存在于结构体」,而是「五个读函数都把它带出来了」——
|
||||
// 漏掉任何一个读路径都不会报错,字段只会静默变成零值。
|
||||
func TestPermissionDeadlineVisibleOnEveryReadPath(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, err := CreateSession(ctx, nil, "agent-d", "等一个决策", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pendingID, err := CreatePermissionMail(ctx, sid, "agent-d", "alice",
|
||||
"删除 build/", "rm -rf build/", []string{"同意", "拒绝"}, "permission", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreatePermissionRequest(ctx, pendingID, sid, "agent-d",
|
||||
"删除 build/", []string{"同意", "拒绝"}, "rm -rf build/", "permission", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同一会话里再放一条**已决策**的待办:它不该带失效时刻 ——
|
||||
// 已决策的不再是待办,给它一个过期时间只会让界面把历史记录也标成过期。
|
||||
settledID, err := CreatePermissionMail(ctx, sid, "agent-d", "alice",
|
||||
"读取配置", "cat cfg", []string{"同意", "拒绝"}, "permission", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreatePermissionRequest(ctx, settledID, sid, "agent-d",
|
||||
"读取配置", []string{"同意", "拒绝"}, "cat cfg", "permission", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecidePermission(ctx, settledID, "同意"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertPending := func(what string, m *models.Mail) {
|
||||
t.Helper()
|
||||
if m == nil {
|
||||
t.Fatalf("%s 返回 nil", what)
|
||||
}
|
||||
if m.PermissionExpiresAt == nil {
|
||||
t.Fatalf("%s:待决权限没有失效时刻(字段没进该读路径时会是 nil,"+
|
||||
"界面就只能永远显示「待决策」)", what)
|
||||
}
|
||||
want := models.PermissionDeadline(m.CreatedAt)
|
||||
if !m.PermissionExpiresAt.Equal(want) {
|
||||
t.Errorf("%s:失效时刻 = %v,期望 %v(= created_at + 等待窗口)",
|
||||
what, m.PermissionExpiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1) GetMailByID —— 单封详情。
|
||||
got, err := GetMailByID(ctx, pendingID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPending("GetMailByID", got)
|
||||
|
||||
// 2) ListInbox —— 授权页列表的来源。
|
||||
inbox, err := ListInbox(ctx, "alice", "all", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !assertDeadlineIn(inbox, pendingID, t) {
|
||||
t.Error("ListInbox 未带出待决权限的失效时刻")
|
||||
}
|
||||
|
||||
// 3) GetSessionMails —— 会话视图。
|
||||
sessMails, err := GetSessionMails(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !assertDeadlineIn(sessMails, pendingID, t) {
|
||||
t.Error("GetSessionMails 未带出待决权限的失效时刻")
|
||||
}
|
||||
|
||||
// 4) GetSessionMailByID —— 会话内的单封。
|
||||
one, err := GetSessionMailByID(ctx, sid, pendingID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPending("GetSessionMailByID", one)
|
||||
|
||||
// 5) ListSentBy —— Agent 发出的那侧。
|
||||
sent, err := ListSentBy(ctx, "agent-d", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !assertDeadlineIn(sent, pendingID, t) {
|
||||
t.Error("ListSentBy 未带出待决权限的失效时刻")
|
||||
}
|
||||
|
||||
// 反向断言:已决策的那条不该有失效时刻。
|
||||
decided, err := GetMailByID(ctx, settledID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decided.PermissionExpiresAt != nil {
|
||||
t.Errorf("已决策的权限邮件不该带失效时刻,实际 %v", decided.PermissionExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// assertDeadlineIn 在列表里找那封邮件并校验失效时刻。
|
||||
func assertDeadlineIn(mails []models.Mail, id interface{ String() string }, t *testing.T) bool {
|
||||
t.Helper()
|
||||
for i := range mails {
|
||||
if mails[i].ID.String() != id.String() {
|
||||
continue
|
||||
}
|
||||
m := &mails[i]
|
||||
if m.PermissionExpiresAt == nil {
|
||||
return false
|
||||
}
|
||||
return m.PermissionExpiresAt.Equal(models.PermissionDeadline(m.CreatedAt))
|
||||
}
|
||||
t.Fatalf("列表里找不到邮件 %s", id)
|
||||
return false
|
||||
}
|
||||
@ -430,6 +430,7 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||||
if renameReason != nil {
|
||||
m.RenameReason = *renameReason
|
||||
}
|
||||
AttachPermissionDeadline(&m)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@ -499,6 +500,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
|
||||
}
|
||||
mails = append(mails, m)
|
||||
}
|
||||
AttachPermissionDeadlines(mails)
|
||||
return mails, rows.Err()
|
||||
}
|
||||
|
||||
@ -557,6 +559,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
|
||||
}
|
||||
mails = append(mails, m)
|
||||
}
|
||||
AttachPermissionDeadlines(mails)
|
||||
return mails, rows.Err()
|
||||
}
|
||||
|
||||
@ -575,6 +578,32 @@ func CreatePermissionRequest(ctx context.Context, mailID, sessionID uuid.UUID, a
|
||||
return err
|
||||
}
|
||||
|
||||
// AttachPermissionDeadline 给仍未决策的权限请求邮件补上失效时刻。
|
||||
//
|
||||
// 为什么是「推导」而不是查询时算完落库:
|
||||
// - 它是 CreatedAt 的纯函数,存下来就会随时间失真(存的是派生值,不是事实);
|
||||
// - 只有**仍未决策**的 permission_request 才有意义 —— 已决策的不再是待办,
|
||||
// 给它一个「失效时刻」只会让界面把历史记录也标成过期。
|
||||
//
|
||||
// 为什么只在邮件上给「时刻」而不给「是否失效」:
|
||||
//
|
||||
// 布尔值是发送那一刻的快照,经 SSE 缓存到客户端后会永久停在旧值。
|
||||
// 时刻是持久事实,任何客户端在任何时候都能自己比出现在过没过期。
|
||||
func AttachPermissionDeadline(m *models.Mail) {
|
||||
if m == nil || m.MailType != "permission_request" || m.PermResult != "" {
|
||||
return
|
||||
}
|
||||
d := models.PermissionDeadline(m.CreatedAt)
|
||||
m.PermissionExpiresAt = &d
|
||||
}
|
||||
|
||||
// AttachPermissionDeadlines 是切片版本,供列表读路径一次处理。
|
||||
func AttachPermissionDeadlines(mails []models.Mail) {
|
||||
for i := range mails {
|
||||
AttachPermissionDeadline(&mails[i])
|
||||
}
|
||||
}
|
||||
|
||||
func DecidePermission(ctx context.Context, mailID uuid.UUID, decision string) (*models.PermissionRequest, error) {
|
||||
var pr models.PermissionRequest
|
||||
var optsJSON []byte
|
||||
@ -618,6 +647,7 @@ func ListPendingPermissions(ctx context.Context) ([]models.PermissionRequest, er
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(optsJSON, &pr.Options)
|
||||
pr.ExpiresAt = models.PermissionDeadline(pr.CreatedAt)
|
||||
reqs = append(reqs, pr)
|
||||
}
|
||||
return reqs, nil
|
||||
@ -672,6 +702,7 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode
|
||||
if alias != nil {
|
||||
m.SessionAlias = *alias
|
||||
}
|
||||
AttachPermissionDeadline(&m)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@ -1228,6 +1259,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
|
||||
}
|
||||
mails = append(mails, m)
|
||||
}
|
||||
AttachPermissionDeadlines(mails)
|
||||
return mails, rows.Err()
|
||||
}
|
||||
|
||||
@ -1261,6 +1293,7 @@ func ListPendingPermissionsFor(ctx context.Context, forUser string) ([]models.Pe
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(optsJSON, &pr.Options)
|
||||
pr.ExpiresAt = models.PermissionDeadline(pr.CreatedAt)
|
||||
reqs = append(reqs, pr)
|
||||
}
|
||||
return reqs, nil
|
||||
|
||||
Reference in New Issue
Block a user