From f0884d03bede4cfd186f0599b4915943e90dffb9 Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 14 Sep 2026 19:16:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi-bridge):=20=E5=86=B3=E7=AD=96=E5=88=B0?= =?UTF-8?q?=E4=BA=86=E6=B2=A1=E5=94=A4=E9=86=92=E7=AD=89=E5=BE=85=E8=80=85?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E6=95=B4=E6=9D=A1=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E9=93=BE=E6=96=AD=E6=8E=89=EF=BC=88=E7=94=A8=E6=88=B7=E6=8A=A5?= =?UTF-8?q?=E3=80=8C=E6=8E=88=E6=9D=83=E6=9C=BA=E5=88=B6=E6=9C=89=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场(用户:「我发现授权机制有问题,你看看 webui4frpc 的那个 session」): 同一条会话一天被问 6 次「是否允许执行 bash?」,**每次人都在 8 秒内点了同意**, 而每一轮都恰好烧满 10 分钟(TURN_TIMEOUT_MS),回信只有一句 59 字的开场白。 该 agent 自己的会话转录里写着:**"The bash tool keeps returning 'No result provided'"**。 根因:`worker.mjs` 的 `permission_decision` 分支取出了等待者、删了表项、存了备注, **却没有调用 `resolve`**: const resolve = pending.get(msg.relayKey); if (!resolve) return; pending.delete(msg.relayKey); decidedExtra.set(msg.relayKey, { ... }); return; // ← 等的人永远醒不过来 于是一条命令走完下面这一整圈: ① 工具调用挂着不动 → 一轮跑到 10 分钟 TURN_TIMEOUT_MS 才结束; ② 桥把模型那半句开场白当「本轮总结」发回(59 字); ③ 会话里留下**没有 toolResult 的 toolCall** → 下一轮 pi SDK 给它补一条 `isError: true` 的「No result provided」→ 模型重试 bash → 人又被问一遍。 为什么之前全绿:`permission-note.test.mjs` 的 WIRING 钉的是 `decidedExtra.set(msg.relayKey)`(**备注=装饰**)与 `renderDecisionReason`, **没有一条钉"唤醒"**。2026-09-13 那次修备注时把唤醒弄丢,判据照样全绿 —— 钉装饰不钉机制。 改: - `resolve(msg.decision)` 补回,放在 `decidedExtra.set` **之后**(hook 醒来要读备注渲染 拒绝理由,顺序反了会复现 2026-09-13 的「备注丢失 → 模型重复追问」) - 判据:WIRING 补一条「必须唤醒」;另加 `checkDecisionBranch` **按标记切出决策分支正文** 判"存在 + 归属 + 顺序"(不用"相距 N 字符"的窗口断言 —— 第一版就是那样假红的) - 三种反面写法做变异自检:删掉 resolve / resolve 早于备注 / resolve 挪出分支,都必须红 - 全套 445 条通过 跨端核对:dsh 桥的 `pendingApprovals` 有 `pending.resolve(outcome)`(没这个问题); opencode 走原生 permission 回复、zcode 走事件钩子 —— 这条路径只有 pi 桥有。 --- plugins/pi-mail-bridge/src/worker.mjs | 24 +++++++ .../test/permission-note.test.mjs | 65 ++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index ad634c1..746871a 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -696,6 +696,30 @@ process.on('message', (msg) => { note: typeof msg.note === 'string' ? msg.note : '', waiting: Array.isArray(msg.freshMails) ? msg.freshMails : [], }); + + /* + * ★★ 必须**最后**唤醒等待者,而且**必须唤醒**——这一行曾经不存在。 + * + * 顺序:上面的 `decidedExtra.set` 要先落地。hook 醒来后会去读它渲染拒绝理由, + * 先 resolve 就会读到空备注(2026-09-13 那次修的正是这个:人写了 + * 「我说了让你拉取仓库到 program 下你听不懂吗」,模型只看到「拒绝」, + * 转头把同一条命令又问了一遍,连问 9 次)。 + * + * 但那次修**只补了备注,把唤醒弄丢了**:`const decision = await new Promise(...)` + * (文件上方的 tool_call 钩子)永远等不到 resolve ⇒ 后果不是"备注少了", + * 而是**整条授权链断掉**: + * ① 工具调用挂着不动 → 一轮跑到 10 分钟 `TURN_TIMEOUT_MS` 才结束; + * ② 桥把模型那半句开场白当"本轮总结"发回去(生产实测 59 字); + * ③ 会话里留下一个**没有 toolResult 的 toolCall** → 下一轮 pi SDK 给它补一条 + * `isError: true` 的「No result provided」→ 模型重试 bash → 人又被问一遍。 + * 实测(webui4frpc 那条会话,2026-09-14):同一条会话一天被问了 6 次 bash, + * 每一次都是人 8 秒内点了「同意」,而每一轮都恰好烧满 10 分钟。 + * + * 判据侧当时为什么没红:`permission-note.test.mjs` 的 WIRING 只钉了 + * `decidedExtra.set(...)`(装饰)与 `renderDecisionReason(...)`, + * **没有一条钉"唤醒"**。现在补上了。 + */ + resolve(msg.decision); return; } if (msg?.type === 'shutdown') { diff --git a/plugins/pi-mail-bridge/test/permission-note.test.mjs b/plugins/pi-mail-bridge/test/permission-note.test.mjs index a57b27f..593b470 100644 --- a/plugins/pi-mail-bridge/test/permission-note.test.mjs +++ b/plugins/pi-mail-bridge/test/permission-note.test.mjs @@ -116,7 +116,22 @@ export const WIRING = [ must: /function routePermission\(relayKey, decision, note/ }, { file: 'worker.mjs', what: 'worker 收下 msg.note(并暂存到 decidedExtra)', must: /decidedExtra\.set\(msg\.relayKey/ }, - { file: 'worker.mjs', what: '拒绝理由由 renderDecisionReason 渲染(而不是写死的字面量)', + /* + * ★ 2026-09-14:这条是补的,因为上面那条**只钉住了备注(装饰),没钉住唤醒**。 + * + * 生产实况(webui4frpc 那条会话):`resolve` 被弄丢之后,工具调用永远等不到结果 —— + * 一轮烧满 10 分钟 TURN_TIMEOUT_MS、发回一句 59 字的开场白、会话里留下一个 + * 没有 toolResult 的 toolCall,下一轮 SDK 补「No result provided」,模型重试 bash, + * 人又被问一次。同一条会话一天被问 6 次,而每次人都在 8 秒内点了同意。 + * 备注接线全绿,机制整条断掉 —— 判据钉装饰不钉机制,就是这个下场。 + * + * 这里只钉"存在";**顺序**(先落备注再唤醒)与**分支归属**由下面的 + * `checkDecisionBranch` 用结构取正文来判 —— 不用"两个形状相距 N 字符"那种窗口断言, + * 那类断言会因为中间多写一段注释而假红(这一条的第一版就是这么红的)。 + */ + { file: 'worker.mjs', what: '决策必须**唤醒**等待中的工具调用(只存备注不算)', + must: /resolve\(msg\.decision\)/ }, +{ file: 'worker.mjs', what: '拒绝理由由 renderDecisionReason 渲染(而不是写死的字面量)', must: /reason: renderDecisionReason\(/ }, { file: 'index.mjs', what: '决策事件把 note 传给 pool', must: /routePermission\(relayKey, decision, note, waiting\)/ }, @@ -141,3 +156,51 @@ test('★ 判据自检:拿一段没有该形状的源码喂进来必须判红' assert.equal(one.must.test(fake), false, '接线断言对"只传 decision"的旧写法必须判红'); assert.equal(one.must.test(read(one.file)), true, '对当前源码必须判绿'); }); + +/* + * ★ 决策分支的**结构**检查(2026-09-14)。 + * + * 抽成函数是为了能被两侧验证:真实源码必须绿;三种反面写法必须红。 + * 用"按标记切出分支正文"而不是"两个形状相距 N 字符": + * 后者会因为我在这两行之间写了一段注释就假红(第一版就是这样红的)。 + */ +export function checkDecisionBranch(src) { + const start = src.indexOf("if (msg?.type === 'permission_decision')"); + const stop = src.indexOf("if (msg?.type === 'shutdown')", start); + if (start < 0 || stop < 0) return { branch: '', setAt: -1, resolveAt: -1, ok: false }; + const branch = src.slice(start, stop); + const setAt = branch.indexOf('decidedExtra.set(msg.relayKey'); + const resolveAt = branch.indexOf('resolve(msg.decision)'); + return { branch, setAt, resolveAt, ok: setAt > 0 && resolveAt > 0 && setAt < resolveAt }; +} + +test('★ 决策分支:先落备注、再唤醒(缺唤醒整条授权链断掉;顺序反了备注被丢掉)', () => { + const r = checkDecisionBranch(read('worker.mjs')); + assert.ok(r.setAt > 0, '决策分支里要暂存备注(decidedExtra.set)'); + assert.ok(r.resolveAt > 0, + '决策分支里必须 resolve(msg.decision) —— 缺了它工具调用永远等不到结果:' + + '一轮烧满 TURN_TIMEOUT_MS、会话留下没有 toolResult 的 toolCall、下一轮模型看到 ' + + '「No result provided」并重问,人一天被问 6 次(webui4frpc 实测)'); + assert.ok(r.setAt < r.resolveAt, '必须先 decidedExtra.set 再 resolve(hook 醒来要读备注渲染拒绝理由)'); +}); + +test('★ 判据自检:三种写法里那两种坏的必须判红', () => { + const good = read('worker.mjs'); + assert.equal(checkDecisionBranch(good).ok, true, '当前源码必须判绿'); + + // ① 生产事故那次:整行 resolve 被删掉 + const missing = good.replace(' resolve(msg.decision);\n', ''); + assert.equal(checkDecisionBranch(missing).ok, false, '删掉 resolve 的写法必须判红'); + + // ② 2026-09-13 那次:resolve 写在 decidedExtra.set 之前(备注丢失 → 模型重复追问) + const reordered = good + .replace(' decidedExtra.set(msg.relayKey, {', ' resolve(msg.decision);\n decidedExtra.set(msg.relayKey, {') + .replace(' resolve(msg.decision);\n return;', ' return;'); + assert.equal(checkDecisionBranch(reordered).ok, false, 'resolve 早于 decidedExtra.set 必须判红'); + + // ③ 唤醒写到了别的分支(形状在、机制不在) + const moved = good + .replace(' resolve(msg.decision);\n return;', ' return;') + .replace("if (msg?.type === 'shutdown') {", "if (msg?.type === 'shutdown') {\n resolve(msg.decision);"); + assert.equal(checkDecisionBranch(moved).ok, false, 'resolve 挪出决策分支必须判红(形状在≠机制在)'); +});