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

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