From bbddee26b91e3604edd57466d89421f6dd577010 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 22:02:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(permission):=20=E5=BE=85=E5=8A=9E=E5=B8=A6?= =?UTF-8?q?=E4=B8=8A=E5=A4=B1=E6=95=88=E6=97=B6=E5=88=BB=EF=BC=9B=E8=B6=8A?= =?UTF-8?q?=E7=AA=97=E7=9A=84=E5=86=B3=E7=AD=96=E4=B8=8D=E5=86=8D=E5=81=87?= =?UTF-8?q?=E8=A3=85=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 起因:一次端到端验证暴露的静默缺口 建了示例工程让 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 --- client/electron/src/api/client.ts | 10 +- client/electron/src/components/MailView.tsx | 32 +++- .../src/components/PermissionList.tsx | 23 +++ client/electron/src/types/index.ts | 11 ++ .../test/components/PermissionPanel.test.tsx | 62 ++++++++ server/internal/handler/permission.go | 42 +++++- .../handler/permission_decide_test.go | 92 ++++++++++++ server/internal/models/models.go | 12 ++ server/internal/models/permission_mode.go | 21 +++ .../internal/models/permission_mode_test.go | 28 +++- .../internal/repo/permission_deadline_test.go | 141 ++++++++++++++++++ server/internal/repo/repo.go | 33 ++++ 12 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 server/internal/handler/permission_decide_test.go create mode 100644 server/internal/repo/permission_deadline_test.go diff --git a/client/electron/src/api/client.ts b/client/electron/src/api/client.ts index bbf8f1c..8572c78 100644 --- a/client/electron/src/api/client.ts +++ b/client/electron/src/api/client.ts @@ -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 ?? '' diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 6f9ec6e..5243b2d 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -607,13 +607,40 @@ export function PermissionPanel({ mail }: { mail: Mail }) { const [decided, setDecided] = useState(mail.permission_result || ''); // 问题模式下已勾选的选项(多选时是多个)。 const [picked, setPicked] = useState([]); + // 服务端判定这条决策越过了等待窗口时回给我们的说明。 + 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 ? ( +

+ {staleWarning || + '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'} +

+ ) : 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 }) {
已处理: {decided} + {staleBanner}
); } @@ -649,6 +677,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) { return (
+ {staleBanner}
{options.length === 0 ? '这题没有预设选项,请直接填写回答:' @@ -709,6 +738,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) { return (
+ {staleBanner}
{options.map(opt => (