Files
MailUI4Agents/plugins/pi-mail-bridge/test/permission-note.test.mjs
JianFeeeee 4175c0ba45 fix(bridge): ★ 投递即标已读 —— 修「桥重启 → 重投 → 回声」
用户 2026-09-26 原话:
  「我都不记得我下达这个任务,是你的桥自动重投存在 bug」
  「就是你的错误的重投机制造成了回声」

# 我上一轮把因果搞反了

我先认定是「两个 Agent 自发辩论」,还为此写了第三道防线(数 Agent↔Agent
连续往返)。**方向错了** —— 是**桥把同一封信反复投递**,每次投递起一个
worker 回信,回信又触发下一轮。模型在做什么?它在回答一封被重复投进来的
旧信。用户根本不知道有这回事。

# 根因:deliveredMails 只在内存,库里的 status 从没被写

投递路径(SSE `new_mail` / 心跳补投 / 决策回执)只做两件事:起 worker、
把 id 记进 `deliveredMails`。**没有任何一处调 `/mail/read`** —— 桥里唯一
那处标已读在 `read_inbox` 工具里,要等模型自己去读收件箱。

于是每封被投递的信**永远是 unread**;而 `catchUp` 按 `status=unread` 拉
⇒ 桥一重启(**每次部署都会**),积压的"未读"被当成离线漏投**再投一遍**。

# 实证(不是推断)

· 5 个 mail_id 各出现在**两条不同 pi 会话**里:
    f06129f4 → 04:54:45 投进 01a0a2bd
             → 08:01:20 投进 01a0daf0
  (而那封信库里已有 1 封回信 —— 它早就被处理过)
· 同一封信被投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
· pi 收件箱 287 封 unread 中 **187 封已经回过信了**
  (`EXISTS(SELECT 1 FROM mails r WHERE r.parent_mail_id=m.mail_id)`)
· 两条会话各烧到 463 / 268 封
· 桥侧:同一邮件会话 id 前缀 `01a0a2bd` 出现在 **4 个** pi 会话文件里
  (投了两次 + 别的历史残留)

# 修法:内存与库必须同时写

`deliveredMails` 是**内存**集合,重启即丢;数据库的 status 才是跨重启的
"我接管过了"记录。两者只写其一 ⇒ 口径不一致 ⇒ 重投。

新增 `markDelivered(id)`:**凡是标记"我接管了这封"的地方都走它**
(SSE / 补投 / 决策回执三个投递点),同时写内存与库。漏一处就是一条重投
路径 —— 这正是缺陷的形状(四处各自 add,没有一处标已读)。

标已读只改 status,不改内容、不删行;`read_inbox` 传 `status=all` 照常可见。
而"已交给一个 worker 处理"正是那封信此刻的真实状态 —— 库里本来就该记这件事,
而不是"模型有没有顺手调过 read_inbox"。

# 四个桥:三个有缺陷,第四个早已修过

| 桥 | 投递标已读 | 说明 |
| --- | --- | --- |
| pi | ✗ → ✓ | 三处 add 都不标 |
| opencode | ✗ → ✓ | 同上 |
| dsh | ✗ → ✓ | 同上 |
| **homeagent** | **✓ 早有** | `ledger` 落盘,跨进程 |

homeagent 不用这个修法:它的 `ledger` 记 `delivered`/`completed` 两个状态,
只有 `completed` 才跳过(投过但被中断的**仍然重投**并带说明)—— 那份设计的
注释里就写着 18:59:38 那次实测,比我今天这个修法更早也更完整。
所以对它只做了「补投按工作区收窄」那一半(见下条)。

# 附带修:homeagent 的 workspace 收窄(我今天打破了它)

我先部署服务端(缺 workspace 直接 400)并修了三个桥,**漏了 homeagent**
⇒ 线上 07:42 起持续报 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`。
这是我造成的,靠自己的日志发现的(pid 还是重启前的旧进程 2291455)。

修法与另三个同源:`currentWorkspace`(信封的 `to_workspace`)在回合期间暂存
(与 `currentSessionID` 同一形状、同一生命周期),`inboxURL`/`scopeQuery` 带上它,
补投从"读一次全局收件箱"改为逐工作区(清单来自心跳的 `pending_workspaces`)。

# 清理重投燃料

151 封归档(80 封回声:会话全程无人类 + 71 封 `permission_decision` 不可投)。
★ 用 `archived` 而不是 `read` —— `read` 还能被 `status=all` 拉出来重投。
判据用服务端自己的口径(`unreadFor` = `m.status<>'archived'` 且
`mail_reads` 无该读者),不手写 SQL 猜语义。

后置:pi / dsh / opencode / homeagent 在**所有工作区**的 unread 全部为 0。

# 判据

· `delivery-marks-read.test.mjs` × 3(pi / opencode / dsh)各 4 条:
  核心那条钉的是「`deliveredMails.add` **只允许**出现在 markDelivered 内部」——
  任何别处直接 add 就是绕过标已读的重投路径。另加自检反例。
  变异验证:绕过投递点 / markDelivered 不写库 / 补投绕过,三处全判红。
· `inbox_workspace_test.go`(homeagent)5 条:URL 带 workspace、带不到时不带
  (让服务端 400:错误可见好过静默越界)、补投逐工作区、两处投递路径都设工作区
  且都清空。变异 3 处全判红。
· 改了两条既有判据(pi / dsh 的 permission-note):原来钉
  `deliveredMails.add(decisionMailID)` —— 那个形状**就是**缺陷载体。
  语义没变(仍"不再当新任务"),载体变了。

全量:pi 517 / opencode 344 / dsh 407 / homeagent 除一条既有的
`TestSDKPinMatchesBuildMachinePointer`(依赖构建机路径,改动前后同样红)全绿。
2026-09-26 09:18:39 +08:00

211 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 「人类对权限请求的说明必须到达模型」—— 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/ },
/*
* ★ 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\)/ },
// ★ 2026-09-26:这一条原来钉的是 `deliveredMails.add(decisionMailID)` ——
// 直接写内存集合。那个形状正是「桥重启后重投」缺陷的载体(deliveredMails
// 只在内存、库里的 status 从没被写)。现在统一走 markDelivered:
// 内存与库一起写。语义没变(仍"不再当新任务"),载体变了。
{ file: 'index.mjs', what: '决策回执记成已交付(不再当新任务)',
must: /markDelivered\(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, '对当前源码必须判绿');
});
/*
* ★ 决策分支的**结构**检查(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 挪出决策分支必须判红(形状在≠机制在)');
});