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

@ -0,0 +1,143 @@
/**
* 「人类对权限请求的说明必须到达模型」—— 2026-09-13 线上缺陷的判据。
*
* # 缺陷现场
*
* 用户在界面上拒绝一条 bash 请求并写下备注:
*
* 拒绝
*
* 备注: 我说了让你拉取仓库到program下你听不懂吗
*
* 而模型那一侧收到的工具结果只有:
*
* 用户拒绝了这次 bash 调用
*
* pi 会话转录可查(`~/.pi/agent/sessions/--home-program--/<id>.jsonl`)。模型于是
* 不知道要改什么,把同一条命令换个写法又问一遍 —— 现场连问 9 次。
*
* # 为什么既验纯函数又验接线
*
* 备注在 `index.mjs → pool.mjs → worker.mjs` 三处被逐个丢掉:纯函数测试对它无能为力
* (函数是对的,只是没人把参数传下去),而只验接线又验不出"渲染出来的字对不对"。
* 所以两层都要:纯函数验文本,接线验参数真的被透传(与 permission-forward-wiring 同一取舍)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderDecisionReason } from '../lib/denial-reason.js';
import { selectWaitingMails } from '../lib/waiting-mails.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src');
const read = (f) => readFileSync(join(SRC, f), 'utf8');
const NOTE = '我说了让你拉取仓库到program下你听不懂吗';
test('备注必须出现在模型看到的拒绝理由里', () => {
const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: NOTE });
assert.match(out, /用户拒绝了这次 bash 调用/);
assert.ok(out.includes(NOTE), `理由里必须原样带上人类写的备注,实际:${out}`);
});
test('反向对照:没写备注时不该凭空造出"用户的说明"', () => {
const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: '' });
assert.ok(!out.includes('用户的说明'), `无备注却出现了说明段:${out}`);
assert.match(out, /^用户拒绝了这次 bash 调用$/);
});
test('等人期间新到的邮件要附进理由,并指向 read_inbox', () => {
const waiting = ['[unread] jianf: 不对不是让你拉取到agentmail仓库是让你拉取到program仓库'];
const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: NOTE, waiting });
assert.ok(out.includes('是让你拉取到program仓库'), '更正内容必须出现在理由里');
assert.match(out, /read_inbox/, '要点明让模型去读全文(理由里只有摘要)');
assert.match(out, /1 封/, '要说明有几封,模型才知道要不要再拉');
});
test('关停路径的文案不回归(未及决策 ≠ 拒绝)', () => {
assert.match(renderDecisionReason({ toolName: 'bash', decision: 'shutdown' }),
/未及决策(桥已关停)/);
});
// ─── selectWaitingMails ───
const mk = (over = {}) => ({
mail_id: 'm-' + Math.random().toString(36).slice(2, 8),
session_id: 's1', mail_type: 'normal', from_name: 'jianf',
subject: 'Re: 权限请求 - 同意', body: '不对不是让你拉取到agentmail仓库是让你拉取到program仓库',
...over,
});
test('只挑这条会话、非权限类、且没交付过的邮件', () => {
const seen = new Set(['m-seen']);
const box = { mails: [
mk({ mail_id: 'm-seen' }),
mk({ mail_id: 'm-other-session', session_id: 's2' }),
mk({ mail_id: 'm-req', mail_type: 'permission_request' }),
mk({ mail_id: 'm-dec', mail_type: 'permission_decision' }),
mk({ mail_id: 'm-keep' }),
] };
const out = selectWaitingMails(box, { sessionID: 's1', seen });
assert.equal(out.length, 1, `只该有 1 封,实际:${JSON.stringify(out)}`);
assert.ok(out[0].includes('是让你拉取到program仓库'));
});
test('★ 反向对照:旧的权限回执不能被当成"人类的新更正"', () => {
const box = { mails: [mk({ mail_type: 'permission_decision' })] };
assert.deepEqual(selectWaitingMails(box, { sessionID: 's1' }), []);
});
test('上限生效(再多也不能把工具结果撑爆)', () => {
const box = { mails: Array.from({ length: 9 }, () => mk()) };
assert.equal(selectWaitingMails(box, { sessionID: 's1', limit: 3 }).length, 3);
});
test('回包没有 session_id 时不能把邮件全丢掉(宁可多给也不能漏更正)', () => {
const box = { mails: [mk({ session_id: undefined })] };
assert.equal(selectWaitingMails(box, { sessionID: 's1' }).length, 1);
});
test('空回包/字段缺失不抛异常', () => {
assert.deepEqual(selectWaitingMails(undefined, {}), []);
assert.deepEqual(selectWaitingMails({ mails: [null, {}, mk({ mail_id: undefined })] }, {}), []);
});
// ─── 接线:参数必须真的被传下去 ───
/** 找「决策从桥到模型」这条链上必须存在的形状。 */
export const WIRING = [
{ file: 'pool.mjs', what: 'routePermission 接住备注与等人期间的邮件',
must: /child\.send\(\{ type: 'permission_decision'[^}]*\bfreshMails\b/ },
{ file: 'pool.mjs', what: 'routePermission 签名带 note',
must: /function routePermission\(relayKey, decision, note/ },
{ file: 'worker.mjs', what: 'worker 收下 msg.note并暂存到 decidedExtra',
must: /decidedExtra\.set\(msg\.relayKey/ },
{ file: 'worker.mjs', what: '拒绝理由由 renderDecisionReason 渲染(而不是写死的字面量)',
must: /reason: renderDecisionReason\(/ },
{ file: 'index.mjs', what: '决策事件把 note 传给 pool',
must: /routePermission\(relayKey, decision, note, waiting\)/ },
{ file: 'index.mjs', what: '决策回执记成已交付(不再当新任务)',
must: /deliveredMails\.add\(decisionMailID\)/ },
{ file: 'index.mjs', what: 'new_mail 分支认得决策回执',
must: /mail_type === 'permission_decision'/ },
{ file: 'turn.mjs', what: '通知投递路径也带备注',
must: /lines\.push\(`用户的说明:\$\{note\}`\)/ },
];
test('接线齐全(缺一处备注就断在那一环)', () => {
for (const w of WIRING) {
const src = read(w.file);
assert.ok(w.must.test(src), `${w.file}: ${w.what}`);
}
});
test('★ 判据自检:拿一段没有该形状的源码喂进来必须判红', () => {
const fake = "entry.child.send({ type: 'permission_decision', relayKey, decision });";
const one = WIRING[0];
assert.equal(one.must.test(fake), false, '接线断言对"只传 decision"的旧写法必须判红');
assert.equal(one.must.test(read(one.file)), true, '对当前源码必须判绿');
});