Files
MailUI4Agents/plugins/dsh-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

55 lines
3.2 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.

/**
* 「人类的说明必须到达模型」—— dsh 桥的接线判据(2026-09-13 线上缺陷)。
*
* 缺陷现场:人类在界面上拒绝一条 bash 请求并写「我说了让你拉取仓库到program下你
* 听不懂吗」,而桥只把 `决策` 一个词给模型(`note` 在 SSE 回包里没人读)⇒ 模型把
* 同一条命令换个写法又问一遍(连问 9 次)。
*
* # 为什么验源码形态
*
* dsh 桥的入口是 Cordis 插件工厂,不是可导入的模块(拉起来要整个 Cordis 运行时),
* 而这里要钉住的只有一件事:**那个 note 还在、且落在给模型的那句话上**。
* 与 pi 桥同源的那条注释是"接线缺口纯函数测不出来"的教训。
*/
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';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8');
test('权限结论的提示词带上了人类的说明', () => {
const fn = SRC.match(/function permissionPrompt\(data: any\): string \{[\s\S]*?\n\}/);
assert.ok(fn, 'permissionPrompt 必须存在');
assert.match(fn[0], /note/, '提示词里必须用到 note');
assert.match(fn[0], /用户的说明/, '要显式写成"用户的说明:…",模型才认得出这是人的要求');
});
test('★ 只有一处拼"已有结论"(三处调用点都走同一个 helper)', () => {
const hits = [...SRC.matchAll(/你之前发起的权限请求已有结论/g)];
assert.equal(hits.length, 1, `应只有 helper 内部那一处,实际 ${hits.length} 处(分叉就是漏信息的地方)`);
assert.match(SRC, /permissionPrompt\(data\)/, '调用点必须用 helper');
});
test('决策回执不再被当成新任务,且记成已交付', () => {
assert.match(SRC, /mail_type === 'permission_decision'/, 'new_mail 分支要认得决策回执');
// ★ 2026-09-26:原来钉 `deliveredMails.add(String(data.decision_mail_id))` ——
// 只写内存集合。那个形状正是「重启后重投」缺陷的载体(内存重启即空,
// 而库里的 status 从没被写过)。现在统一走 markDelivered:内存与库一起写。
// 语义没变(仍"不再当新任务"),载体变了。
assert.match(SRC, /markDelivered\(data\.decision_mail_id\)/, '收到决策时要记下 decision_mail_id');
});
test('平台回执带不了理由 → 带说明的决策要另投一趟通知', () => {
assert.match(SRC, /data\.note\.trim\(\)\)/, '有说明时才另投(空说明不投)');
});
test('★ 判据自检:拿缺陷时的源码形态喂进来必须判红', () => {
const old = "return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by})。请据此继续后续工作。`;";
assert.equal(/你之前发起的权限请求已有结论/g.test(old), true);
assert.equal(/用户的说明/.test(old), false, '旧形态里没有"用户的说明" ⇒ 上面那条断言会判红');
assert.equal([...old.matchAll(/你之前发起的权限请求已有结论/g)].length, 1);
});