用户 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`(依赖构建机路径,改动前后同样红)全绿。
211 lines
11 KiB
JavaScript
211 lines
11 KiB
JavaScript
/**
|
||
* 「人类对权限请求的说明必须到达模型」—— 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 挪出决策分支必须判红(形状在≠机制在)');
|
||
});
|