fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务

用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
This commit is contained in:
2026-09-13 22:47:25 +08:00
parent 773acd079f
commit 453f451fbb
9 changed files with 364 additions and 14 deletions

View File

@ -44,6 +44,7 @@ import { join } from 'node:path';
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
import { GatewayClient } from './gateway.mjs';
import { renderDecisionReason } from '../lib/denial-reason.js';
import { createMailTools } from './tools.mjs';
import { openSession, runTurn } from './session-pool.mjs';
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError, renderResumeFailure } from './turn.mjs';
@ -89,6 +90,10 @@ const grants = new Set();
let job = null;
let client = null;
// 每次权限询问「人类说了什么」(备注 + 等人期间新到的邮件),按 relayKey 暂存。
// 见 lib/denial-reason.js 的说明。
const decidedExtra = new Map();
let modelRuntime = null;
let piSessionId = '';
let mailContext = { replyTo: '', subject: '', mailID: '', permissionMode: 'workspace' };
@ -237,9 +242,16 @@ function permissionExtension() {
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
return;
}
const extra = decidedExtra.get(relayKey) || {};
decidedExtra.delete(relayKey);
return {
block: true,
reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}`,
reason: renderDecisionReason({
toolName: event.toolName,
decision,
note: extra.note,
waiting: extra.waiting,
}),
};
});
};
@ -654,7 +666,13 @@ process.on('message', (msg) => {
const resolve = pending.get(msg.relayKey);
if (!resolve) return;
pending.delete(msg.relayKey);
resolve(String(msg.decision || '拒绝'));
// 备注与「等人期间新到的邮件」按 relayKey 暂存,等那次工具调用回来渲染
// 拒绝理由时取走。不存全局变量:一个回合里可能有多次询问先后落地。
decidedExtra.set(msg.relayKey, {
note: typeof msg.note === 'string' ? msg.note : '',
waiting: Array.isArray(msg.freshMails) ? msg.freshMails : [],
});
return;
}
if (msg?.type === 'shutdown') {