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:
@ -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 ?? ''
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -324,3 +324,65 @@ describe('PermissionPanel 回答问题', () => {
|
||||
expect(screen.queryByRole('button', { name: /提交回答/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 越窗的待办必须可见。
|
||||
*
|
||||
* 实测(2026-09-11):pi 桥等不到决策时会在回合超时(默认 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user