## 起因:修部署漂移时撞出来的
b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName`,
用来分辨「回的是你那封」与「多方线索里的他人续谈」——单向续信链(8 封全是
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
模型把它当新任务处理。**但只有 pi 桥接了**:
pi parentFrom: data?.parent_from ✓(turn.mjs:148)
dsh 从不传 ✗ 3 处
opencode 从不传 ✗ 1 处
判据函数是三个桥**同一份** relay-policy.js 的拷贝,看起来测过了 ——
但「函数支持」≠「调用方传了」。三个桥各自都有 relay-policy 单元测试且全绿,
因为它们只测那个**纯函数**,没有一个测「桥真的把参数传进去了」。
dsh 还多一层:它有一份**手写**的 `lib/relay-policy.d.ts`,缺这两个字段
⇒ tsc 报 TS2353 ⇒ 调用方即使想传也过不了类型检查。那行判据是被类型系统
**主动拦住**的死代码。只补 .js 不补 .d.ts 等于没补。
## 改动
dsh 3 处 + opencode 1 处调用点补 `parentFrom`/`selfName`,对齐 pi 的样板;
dsh 的 `.d.ts` 补声明(注释写明「光有声明不够,调用点也得传」)。
## 新增判据 deploy/check-parent-from.mjs(7 格)
不只是查「有没有传」,还查**三处曾经不一致的接缝**:
① 每个 inboundHeadline 调用点都传了 parentFrom(次数写死,新增调用点
忘了传要能被发现,而不是被 `>0` 静默接受)
② 传了 parentFrom 就必须传 selfName —— 只传前者时
`parentFrom === selfName` 永不成立,判据形同虚设
③ 三份 relay-policy.js 都真的有 `parentFrom === selfName`
④ dsh 的 .d.ts 声明与实现一致(否则 tsc 拦住,判据等于没有)
**变异验证**:撤一处传参 → 红 2;只撤 selfName → 红 1;撤 .d.ts 声明 → 红 1。
## ★ 判据自身也踩了两次坑(都已修)
1. 初版 `.d.ts` 那格用 `\bparentFrom\b` 全文件搜 ⇒ 撤掉字段声明后,
**注释文字里还留着这个词** ⇒ 照样匹配 ⇒ 变异不红(假绿)。
改为只认字段声明 `^\s*parentFrom\??\s*:\s*string\s*;`。
2. 初版 `.mjs` 写了 `require` ⇒ ReferenceError(ESM)。已改 import。
## 顺带修掉 6 个陈旧判据(都在 HEAD 上、此前静默红着)
`turn.test.mjs` 的「回信到达时明说不是新任务」:只造 `in_reply_to` 却断言
`/m-0/`,而 `b0c8719` 新增的「我的回复」分支文案里压根不含父邮件 ID ——
判据没跟上那次改动。已改成覆盖三种情形(parent_from 是我 / 是别人 / 缺席)。
★ 我第一版改完是**假绿**:只断言 `/续谈/` 时,把 `mine` 分支改坏也会走
「续谈」分支而照样通过。加反向断言(mine 不含「续谈」、theirs 不含
「不是新任务」)后才抓住。变异 A/B 各 35 pass / 1 fail。
`cross-bridge-permission-routing.test.mjs`:zcode 的路径与目录还指向
`d09ef39` 已删的 `hooks/`、`mcp/` ⇒ ENOENT。zcode 确实**没有** 409 分支了
(移除执行类工具所致,非缺陷)⇒ `permanent: null` 表示不适用,并加反向
守卫:若 `src/` 里出现 `isPermanentFailure` 就红(防止前提变了却继续空转)。
`isPermanentFailure` 是 `lib/relay-key.js` 的通用网关辅助,与权限放行无关 ——
踩过一次:把 `lib` 加进扫描范围后对着共享辅助函数误报。
全量:pi 533 / opencode 364 / dsh 433,零红(此前 4 红)。
## 部署与端到端
pi / opencode / dsh 三个宿主均已部署;dsh 已真实收发验证:
[dsh-mail-bridge] 本轮不自动转发:来信方 pi 是 Agent…
[dsh-mail-bridge] new_mail -> 新会话 mail-8e8bf319…
119 lines
5.6 KiB
JavaScript
119 lines
5.6 KiB
JavaScript
/*
|
||
跨桥一致性判据:`parent_from` 方向判据必须在**每个**桥真正生效(2026-10-02)。
|
||
|
||
# 这个缺陷是怎么活下来的
|
||
|
||
b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName` 两个参数,
|
||
用来分辨「回的是你那封」和「多方线索里的他人续谈」——单向续信链(8 封全是
|
||
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
|
||
模型于是把它当新任务处理。pi 桥接了,**另外两个桥没接**:
|
||
|
||
pi parentFrom: data?.parent_from ✓
|
||
dsh (从不传) ✗ 3 处调用点
|
||
opencode (从不传) ✗ 1 处调用点
|
||
|
||
判据函数在 `lib/relay-policy.js` 里对三个桥是**同一份拷贝**,看起来测过了;
|
||
但「函数支持」≠「调用方传了」。dsh 还多一层:它有一份**手写**的
|
||
`lib/relay-policy.d.ts`,缺这两个字段 ⇒ tsc 报 TS2353 ⇒ 调用方即使想传
|
||
也传不过类型检查 —— 于是那行判据在 dsh 上是被类型系统**主动拦住**的死代码。
|
||
|
||
# 为什么这值得单独一格判据
|
||
|
||
三个桥各自都有 relay-policy 的单元测试,且全绿 —— 因为它们测的是
|
||
`inboundHeadline` 这个**纯函数**,没有一个测「桥真的把参数传进去了」。
|
||
纯函数测试与接线测试是两件事,缺后者就等于没测。
|
||
|
||
用法:在仓库根跑,见 go 之外的 `node deploy/check-parent-from.mjs`。
|
||
*/
|
||
|
||
// 这是 .mjs —— ESM。(初版写了 require,跑起来直接 ReferenceError;
|
||
// 与其它 deploy/check-*.mjs 保持一致用 import。)
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
/** 每个桥:入口文件 + 期望出现 parentFrom 传参的次数。
|
||
* 次数写死而不是「>0」:新增调用点时忘了传参,要能被发现而不是被静默接受。 */
|
||
const BRIDGES = [
|
||
{ name: 'pi', files: ['plugins/pi-mail-bridge/src/turn.mjs'], expect: 1 },
|
||
{ name: 'dsh', files: ['plugins/dsh-mail-bridge/src/index.ts'], expect: 3 },
|
||
{ name: 'opencode', files: ['plugins/opencode-mail-bridge/index.js'], expect: 1 },
|
||
];
|
||
|
||
const PASS = (m) => console.log(` 通过 ${m}`);
|
||
const FAIL = (m) => { console.log(` 失败 ${m}`); failures.push(m); };
|
||
const failures = [];
|
||
|
||
console.log('判据:三个桥都必须把 parent_from 传给 inboundHeadline');
|
||
|
||
for (const b of BRIDGES) {
|
||
for (const rel of b.files) {
|
||
const full = path.join(REPO, rel);
|
||
if (!fs.existsSync(full)) { FAIL(`${b.name}: 文件不存在 ${rel}`); continue; }
|
||
const src = fs.readFileSync(full, 'utf8');
|
||
|
||
// ① 每个 inboundHeadline 调用点都必须带 parentFrom
|
||
const calls = [...src.matchAll(/inboundHeadline\(\s*\{([\s\S]*?)\n\s*\}\)/g)];
|
||
if (calls.length === 0) { FAIL(`${b.name}: 没找到 inboundHeadline 调用点(判据本身失效?)`); continue; }
|
||
const missing = calls.filter((c) => !/parentFrom\s*:/.test(c[1]));
|
||
if (missing.length) {
|
||
FAIL(`${b.name}: ${missing.length}/${calls.length} 个 inboundHeadline 调用点没传 parentFrom ` +
|
||
`⇒ 单向续信链会被误判成回信`);
|
||
}
|
||
const have = calls.filter((c) => /parentFrom\s*:/.test(c[1])).length;
|
||
if (have !== b.expect) {
|
||
FAIL(`${b.name}: 实传 ${have} 处,期望 ${b.expect} 处(新增/漏改调用点)`);
|
||
}
|
||
if (!missing.length && have === b.expect) {
|
||
PASS(`${b.name}: ${calls.length} 个调用点全部传了 parent_from(${rel})`);
|
||
}
|
||
|
||
// ② 传了 parentFrom 就必须传 selfName,否则判据永远不成立
|
||
const noSelf = calls.filter((c) => /parentFrom\s*:/.test(c[1]) && !/selfName\s*:/.test(c[1]));
|
||
if (noSelf.length) {
|
||
FAIL(`${b.name}: ${noSelf.length} 处传了 parentFrom 却没传 selfName ` +
|
||
`⇒ parentFrom === selfName 永不成立,判据形同虚设`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ③ 判据函数本身必须真的认这两个参数(三份拷贝各一份)
|
||
for (const lib of [
|
||
'plugins/pi-mail-bridge/lib/relay-policy.js',
|
||
'plugins/dsh-mail-bridge/lib/relay-policy.js',
|
||
'plugins/opencode-mail-bridge/lib/relay-policy.js',
|
||
]) {
|
||
const full = path.join(REPO, lib);
|
||
if (!fs.existsSync(full)) { FAIL(`判据函数缺失 ${lib}`); continue; }
|
||
const src = fs.readFileSync(full, 'utf8');
|
||
if (!/parentFrom\s*===\s*selfName/.test(src)) {
|
||
FAIL(`${lib}: 方向判据 parentFrom === selfName 不在(调用方传了也没用)`);
|
||
} else {
|
||
PASS(`${lib}: 方向判据在`);
|
||
}
|
||
}
|
||
|
||
// ④ dsh 的手写 .d.ts 必须声明这两个字段,否则 tsc 会拦住调用方
|
||
{
|
||
const full = path.join(REPO, 'plugins/dsh-mail-bridge/lib/relay-policy.d.ts');
|
||
if (fs.existsSync(full)) {
|
||
const src = fs.readFileSync(full, 'utf8');
|
||
// ★ 只认**字段声明**,不能只搜字段名。
|
||
// 初版用 `\bparentFrom\b` 全文件搜 —— 撤掉 `parentFrom?: string;` 后
|
||
// 注释文字里还留着这个词 ⇒ 照样匹配 ⇒ 变异 ③ 不红(假绿,已实测)。
|
||
// 判据本身就是这次事故的一部分,所以这里必须真的能判。
|
||
const missing = ['parentFrom', 'selfName'].filter((f) =>
|
||
!new RegExp(`^\\s*${f}\\??\\s*:\\s*string\\s*;`, 'm').test(src));
|
||
if (missing.length) {
|
||
FAIL(`dsh relay-policy.d.ts 缺声明 ${missing.join(', ')} ` +
|
||
`⇒ TS2353 会让调用方传不了参(这正是它一度是死代码的原因)`);
|
||
} else {
|
||
PASS('dsh relay-policy.d.ts 声明与实现一致');
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(failures.length ? `\n✗ ${failures.length} 处不通过` : '\n✓ 全部通过');
|
||
process.exit(failures.length ? 1 : 0); |