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

@ -562,7 +562,15 @@ export async function archiveContact(payload: { address?: string; session_id?: s
// ---------- Permission ----------
export async function decidePermission(mailId: string, decision: string, note?: string) {
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
// expired/warning请求已越过等待窗口时服务端会带上见后端 decideResponse
// 必须跟着返回类型走,否则界面又把「决策落到了一个没人在等的请求上」吞掉。
return request<{
status: string;
decision_mail_id: string;
expired?: boolean;
expires_at?: string;
warning?: string;
}>('POST', '/permission/decide', {
mail_id: mailId,
decision,
note: note ?? ''

View File

@ -607,13 +607,40 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
const [decided, setDecided] = useState(mail.permission_result || '');
// 问题模式下已勾选的选项(多选时是多个)。
const [picked, setPicked] = useState<string[]>([]);
// 服务端判定这条决策越过了等待窗口时回给我们的说明。
const [staleWarning, setStaleWarning] = useState('');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
// 本地先算一遍「可能已失效」:服务端只给失效**时刻**,当前时间本地就有,
// 不必等一次往返。判定与后端 decideResponse 同一口径(严格晚于)。
const expiredBeforeDecide =
!mail.permission_result &&
!!mail.permission_expires_at &&
Date.now() > Date.parse(mail.permission_expires_at);
/**
* 越窗提示。
*
* 两边都要显:决策**前**(人即将点下去)与决策**后**(人以为事情已经办了)。
* 只在决策后显示等于让人先做错一次;只在决策前显示则补不上服务端在两次渲染
* 之间越窗的情形。
*/
const staleBanner =
staleWarning || expiredBeforeDecide ? (
<p className="mt-2 text-[11px] leading-relaxed text-amber-800 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-1.5">
{staleWarning ||
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'}
</p>
) : null;
const submit = async (decision: string, noteText: string) => {
setBusy(true);
try {
await api.decidePermission(mail.mail_id, decision, noteText || undefined);
const res = await api.decidePermission(mail.mail_id, decision, noteText || undefined);
// 服务端在请求已越过等待窗口时会回 expired + warning这次批准不会恢复
// 当时那次工具调用。必须显示出来 —— 否则人看到「已处理」就以为事情办了。
if (res?.warning) setStaleWarning(res.warning);
setDecided(decision || '(自由文本回答)');
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
@ -629,6 +656,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
<strong className="text-gray-800 whitespace-pre-wrap">{decided}</strong>
{staleBanner}
</div>
);
}
@ -649,6 +677,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<div className="text-[11px] text-gray-500 mb-2">
{options.length === 0
? '这题没有预设选项,请直接填写回答:'
@ -709,6 +738,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button

View File

@ -210,6 +210,15 @@ function PermissionRow({
}) {
const settled = !!mail.permission_result;
const approved = settled && /同意|允许|批准|approve|yes/i.test(mail.permission_result || '');
// 待办是否已越过等待窗口:拿服务端给的**时刻**与本地当前时间比。
//
// 服务端刻意不发「是否失效」的布尔值 —— 那是发出那一刻的快照,经 SSE 缓存到
// 本地后会永久停在 false于是界面会把一条早就没人等的待办一直显示成「等待你
// 决策」。时刻是持久事实,任何时刻都能自己算出结论。
const expired =
!settled &&
!!mail.permission_expires_at &&
Date.now() > Date.parse(mail.permission_expires_at);
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
@ -225,6 +234,9 @@ function PermissionRow({
? 'bg-blue-50 border-blue-200'
: settled
? 'border-transparent hover:bg-gray-50'
: expired
// 失效的不该和还能立刻生效的长得一样:它点了也不会恢复原调用。
? 'border-gray-200 bg-gray-50 hover:bg-gray-100'
: 'border-orange-200 bg-white hover:bg-orange-50'
}`}
>
@ -248,6 +260,17 @@ function PermissionRow({
{approved ? <CheckIcon className="w-2.5 h-2.5" /> : <CloseIcon className="w-2.5 h-2.5" />}
{mail.permission_result}
</span>
) : expired ? (
<span
className="inline-flex items-center gap-0.5 text-[10px] text-gray-500"
title={
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' +
'现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'
}
>
<ShieldIcon className="w-2.5 h-2.5" />
</span>
) : (
<span className="inline-flex items-center gap-0.5 text-[10px] text-orange-600 font-medium">
<ShieldIcon className="w-2.5 h-2.5" />

View File

@ -110,6 +110,17 @@ export interface Mail {
permission_kind?: string;
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select。 */
permission_multi_select?: boolean;
/**
* 这条待办的失效时刻(仅**未决策**的 permission_request 有)。
*
* 超过它之后提出询问的插件很可能已不再阻塞等待pi 桥的回合超时默认也是
* 10 分钟),此时的批准不会恢复当时那次工具调用 —— 决策会被当作一条通知投给
* 它,让它重起一轮。界面据此把这类待办标成「可能已失效」。
*
* 服务端给的是**时刻**而不是「是否失效」的布尔值:布尔值是发出那一刻的快照,
* 经 SSE 缓存后会永久停在旧值;时刻则随时可自行比较。
*/
permission_expires_at?: string | null;
status: 'unread' | 'read' | 'archived';
created_at: string;
hop_limit?: number;

View File

@ -324,3 +324,65 @@ describe('PermissionPanel 回答问题', () => {
expect(screen.queryByRole('button', { name: /提交回答/ })).toBeNull();
});
});
/**
* 越窗的待办必须可见。
*
* 实测2026-09-11pi 桥等不到决策时会在回合超时(默认 10 分钟)拆掉 worker
* 与决策路由表,此后的批准只会作为通知投递给 Agent。而界面当时照样显示「等待你
* 决策」、点下去也照样回「已处理」—— 人以为事情办了,实际什么都没发生。
*
* 判据分两处:决策**前**要能提前看见(人正要按下去),决策**后**要能知道这次
* 批准没恢复原调用(服务端回 warning
*/
describe('PermissionPanel 越窗(可能已失效)', () => {
beforeEach(() => {
vi.restoreAllMocks();
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
});
it('失效时刻已过:决策前就提示「不会恢复原调用」', () => {
const past = new Date(Date.now() - 60_000).toISOString();
render(React.createElement(PermissionPanel, { mail: permMail({ permission_expires_at: past }) }));
// 仍可决策(决策本身有效,桥会当通知投递),但必须讲清会发生什么
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
expect(screen.getByText(/不会恢复当时那次工具调用/)).toBeInTheDocument();
});
it('失效时刻未到:不提示,避免把还在等的待办说成过期', () => {
const future = new Date(Date.now() + 60 * 60_000).toISOString();
render(React.createElement(PermissionPanel, { mail: permMail({ permission_expires_at: future }) }));
expect(screen.queryByText(/不会恢复当时那次工具调用/)).toBeNull();
});
it('没有失效时刻(旧数据):不提示', () => {
render(React.createElement(PermissionPanel, { mail: permMail() }));
expect(screen.queryByText(/不会恢复当时那次工具调用/)).toBeNull();
});
it('决策后服务端回 warning把它显示在「已处理」旁边', async () => {
const warning = '该请求已超过等待窗口10 分钟),发起它的 Agent 很可能已不再阻塞等待。';
vi.spyOn(api, 'decidePermission').mockResolvedValue({
status: 'decided',
decision_mail_id: 'd-1',
expired: true,
warning
} as any);
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['同意', '拒绝'] })
})
);
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
await waitFor(() => {
expect(screen.getByText(/已处理/)).toBeInTheDocument();
});
// 关键:不能只显示「已处理:同意」就完事 —— 那正是静默成功
expect(screen.getByText(warning)).toBeInTheDocument();
});
});

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

View File

@ -153,6 +153,15 @@ type Mail struct {
PermResult string `json:"permission_result,omitempty"`
PermissionKind string `json:"permission_kind,omitempty"`
PermissionMulti bool `json:"permission_multi_select,omitempty"`
// PermissionExpiresAt 是这条权限待办的失效时刻(仅仍未决策的 permission_request 有)。
//
// 超过它之后,提出询问的插件很可能已不再阻塞等待(见 models.PermissionWaitWindow
// 刻意只给**时刻**而不给「已失效」布尔值:布尔值是「发出那一刻」的快照,
// 经 SSE 缓存后会永久停在旧值;时刻则任何客户端都能随时比出现在过没过期。
//
// 由读路径按 CreatedAt 推导后填充,**不落库** —— 它是时间的函数,存下来会失真。
PermissionExpiresAt *time.Time `json:"permission_expires_at,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
HopLimit int `json:"hop_limit"`
@ -226,6 +235,9 @@ type PermissionRequest struct {
Result *string `json:"result"`
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
// ExpiresAt 是等待窗口的截止时刻(见 PermissionWaitWindow
// 客户端用它自行判断「这条待办是否可能已经没人等了」,服务端不替它下结论。
ExpiresAt time.Time `json:"expires_at"`
}
// SSE 事件类型

View File

@ -1,5 +1,7 @@
package models
import "time"
// ─── 权限档位 ───
//
// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行,
@ -33,6 +35,25 @@ const (
//
// 选 workspace 而不是 full默认值应当是「多数任务够用且出错代价可控」的那一档。
// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。
// PermissionWaitWindow 是权限待办的等待窗口。
//
// 超过它之后,提出询问的插件**很可能**已不再阻塞等待 —— pi 桥的回合超时
// (AGENTMAIL_TURN_TIMEOUT_MS) 默认同为 10 分钟,超时即拆掉 worker 与它的决策
// 路由表;此后的决策只会作为通知投递,不再恢复当时那次工具调用。
//
// 服务端并不知道插件此刻是否还在等(那是它进程内的状态),所以这里只用来标出
// 「这封待办已经放了很久」,而不是替插件宣布裁决。
//
// 对外只发**截止时刻**,不发「是否失效」的布尔值:布尔值是「发出那一刻」的
// 快照,推给客户端(尤其经 SSE 缓存)后会永久停在旧值;而截止时刻是持久事实,
// 任何客户端在任何时候都能自己比出结论。
const PermissionWaitWindow = 10 * time.Minute
// PermissionDeadline 返回一条待办的失效时刻created_at + 等待窗口)。
func PermissionDeadline(createdAt time.Time) time.Time {
return createdAt.Add(PermissionWaitWindow)
}
const DefaultPermissionMode = ModeWorkspace
// PermissionModes 是全部合法档位,按宽松程度递增排列。

View File

@ -6,7 +6,10 @@ package models
// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守
// 取向(非法值 → workspace 而非 full是安全属性拼错一个档位名不该换来更大权限。
import "testing"
import (
"testing"
"time"
)
func TestValidPermissionMode(t *testing.T) {
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
@ -164,3 +167,26 @@ func TestPermissionModesOrder(t *testing.T) {
t.Fatalf("PermissionModes 必须按宽松程度递增排列plan < workspace < full得到 %v", PermissionModes)
}
}
// 待办的失效时刻必须是「创建时刻 + 等待窗口」的纯函数。
//
// 这条算式同时被 repo填到邮件上与 handler判断决策是否越窗使用
// 两边必须得出同一个值 —— 各算各的就会出现「界面说已过期、决策说没过期」。
func TestPermissionDeadline(t *testing.T) {
created := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
got := PermissionDeadline(created)
want := created.Add(PermissionWaitWindow)
if !got.Equal(want) {
t.Fatalf("PermissionDeadline = %v期望 %v", got, want)
}
// 纯函数:同样的输入必须给同样的输出,且不受调用时刻影响。
if again := PermissionDeadline(created); !again.Equal(got) {
t.Fatalf("同一输入两次调用结果不同:%v vs %v", got, again)
}
// 窗口本身要是个正数,否则「失效时刻」永远不会到来,界面也就永不过期。
if PermissionWaitWindow <= 0 {
t.Fatalf("等待窗口必须为正,实际 %v", PermissionWaitWindow)
}
}

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

View File

@ -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