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:
@ -46,6 +46,7 @@ import { BoundedSet, MAX_TRACKED_MAILS } from '../lib/bounded.js';
|
||||
import { snapshotPiModels } from '../lib/model-scope.js';
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
import { selectWaitingMails } from '../lib/waiting-mails.js';
|
||||
|
||||
// ─── 配置 ───
|
||||
|
||||
@ -161,19 +162,58 @@ function releaseLock() {
|
||||
* - 那条邮件会话从没被处理过:连通知都无处可投,只能记一行日志。
|
||||
*/
|
||||
function handlePermissionDecision(data) {
|
||||
const relayKey = data.relay_key || '';
|
||||
if (relayKey && pool.routePermission(relayKey, String(data.decision || '拒绝'))) {
|
||||
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker`);
|
||||
// 决策回执的内容马上随这次恢复交给 worker(备注 + 等人期间新到的邮件都在里面),
|
||||
// 所以先把它记成"已交付":稍后那封同内容的邮件就不会被当成新任务再起一轮。
|
||||
const decisionMailID = data?.decision_mail_id || '';
|
||||
if (decisionMailID) deliveredMails.add(decisionMailID);
|
||||
|
||||
// 拉"等人期间新到的邮件"要发一次 HTTP,而本函数跑在 SSE 读循环上(必须廉价)
|
||||
// —— 所以整体转异步:先把事件收下,几毫秒后带着上下文去唤醒 worker。
|
||||
routeDecision(data).catch((e) => log(`决策投递失败: ${describeError(e)}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把决策连同**人类说了什么**一起交给挂起的 worker。
|
||||
*
|
||||
* 缺了 `note` 就是这次线上缺陷:人类写「我说了让你拉取仓库到program下你听不懂吗」,
|
||||
* 模型只收到「用户拒绝了这次 bash 调用」,于是把同一条命令改个写法又问一遍(连问 9 次)。
|
||||
*/
|
||||
async function routeDecision(data) {
|
||||
const relayKey = data?.relay_key || '';
|
||||
const decision = String(data?.decision || '拒绝');
|
||||
const note = typeof data?.note === 'string' ? data.note : '';
|
||||
const sessionID = data?.session_id || '';
|
||||
const waiting = sessionID ? await collectWaitingMails(sessionID) : [];
|
||||
|
||||
if (relayKey && pool.routePermission(relayKey, decision, note, waiting)) {
|
||||
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker`
|
||||
+ (note ? ',带备注' : '')
|
||||
+ (waiting.length ? `,并附上等人期间新到的 ${waiting.length} 封邮件` : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.session_id || !pool.hasSession(data.session_id)) {
|
||||
if (!sessionID || !pool.hasSession(sessionID)) {
|
||||
// **不得凭空新开会话**(B-4.3)
|
||||
log(`权限决策 ${relayKey} 无对应会话,忽略`);
|
||||
return;
|
||||
}
|
||||
log(`权限 ${relayKey} 无挂起项,退化为通知投递`);
|
||||
pool.submit('permission', data);
|
||||
pool.submit('permission', { ...data, note });
|
||||
}
|
||||
|
||||
/**
|
||||
* 等人点头期间新到的邮件(人类很可能在这期间补一句更正)。
|
||||
*
|
||||
* 拉取失败**不能拖垮决策投递**:备注还在,最坏情况是模型晚一步看到更正。
|
||||
*/
|
||||
async function collectWaitingMails(sessionID) {
|
||||
try {
|
||||
const box = await client.get('/mail/inbox?status=unread&limit=20');
|
||||
return selectWaitingMails(box, { sessionID, seen: deliveredMails });
|
||||
} catch (e) {
|
||||
log(`等人期间的邮件拉取失败(不影响决策投递): ${describeError(e)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 心跳(B-2)───
|
||||
@ -372,6 +412,25 @@ function handleSSEEvent(type, data) {
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
// ★ 决策回执**不是新任务**(2026-09-13 线上缺陷)。
|
||||
//
|
||||
// 它长得像普通邮件("Re: 权限请求 - 拒绝"),内容却已经随 SSE 的
|
||||
// permission_decision 直接交给了发起询问的 worker。以前它会**再**起一轮:
|
||||
// 同一件事处理两次,还把人类真正的新邮件挤在这条会话的队列后面 ——
|
||||
// 实测人类的更正 22:18:08 发出,直到 22:26:30 才被模型看到。
|
||||
//
|
||||
// 走到这里说明决策事件还没到(事件先到就已经记进 deliveredMails 了)——
|
||||
// 那就按 B-4.3 当**通知**投进原会话,而不是凭空当新任务。
|
||||
if (data?.mail_type === 'permission_decision') {
|
||||
if (!data?.session_id || !pool.hasSession(data.session_id)) {
|
||||
log(`决策回执 ${id} 无对应会话,忽略(不凭空新开)`);
|
||||
return;
|
||||
}
|
||||
log(`决策回执 ${id} 按通知投递(决策事件尚未到达)`);
|
||||
pool.submit('permission', data);
|
||||
return;
|
||||
}
|
||||
|
||||
pool.submit('mail', data);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user