refactor(pi-bridge): A 方案落地 —— 平台兜底值搬回平台侧,workspace.js 恢复逐字节相同

pi 定的 A(2026-09-15)。要点是:**契约的逃逸口不是豁免清单,而是
`resolveWorkspaceCwd(workspace, fallback)` 的第二个参数** —— 平台兜底值本来就该由平台侧传进去。
四个桥对照很清楚:

| 桥       | 平台兜底值在哪                                   | 怎么交给共用函数 |
|----------|--------------------------------------------------|------------------|
| opencode | 自己的 `index.js`                                | 传 `directory`   |
| zcode    | 自己的 `src/index.mjs`(`zcodeSessionFallback`) | 传进去           |
| dsh      | 共用的 `mailSessionFallback`(写死 `.dsh`)      | 直接用           |
| pi       | **原来造在共用模块里**(本次出的错)             | → 现在也传进去   |

⇒ `docs/PLUGIN-CONTRACT.md` 第 1150 行**不用改、也不该加旁路**:pi 只是唯一一个把平台值
造在共用模块里的,挪回平台侧就恢复了规矩。

**改动(与 pi 预测的形状一致)**:`workspace.js` 删掉那 15 行、`pool.mjs`/`worker.mjs`
各改一行 import,外加新文件 `src/paths.mjs`。`git diff --stat` 实测
`15 -` / `3 +-` / `3 +-` —— 没有多余改动。

**为什么新家是 `src/paths.mjs` 而不是 `lib/sandbox.js`**(pi 给了两个选项,我选前者):
`lib/` 按契约是"**候选共用**"目录,把一个 pi 专有文件放进去**正是这次出事的形状** ——
下一个人会问"它为什么不在 `ALL_LIBS` 里"。`src/` 下同名文件不会引起这个问题。
父子同源(worker 的沙箱 rw 由父进程算)由"两边 import 同一个模块"继续满足。

**验收四条(pi 给的,逐条实测)**:
1. `cmp opencode/lib/workspace.js pi/lib/workspace.js` **相同**;
   `check-shared-libs.sh` **exit 0**;`install.sh --check` **exit 0** ✓
2. 测试数**不降**:491 → **496**(+5,见下)✓
3. 给 `piMailFallback` **补测试**(原先一条都没有 —— 这正是当初的不对称:
   四个桥 `npm test` 全绿、只有 `check-shared-libs` 抓得到)→ 新增
   `test/pi-paths.test.mjs` 5 条 ✓
4. 四个调用点改 import 后 diff 只应是 import 行 + 删掉的那 15 行 ✓

**新测试为什么是独立文件**:`test/workspace.test.mjs` 在四个桥里**逐字节相同**
(md5 一致,属共用测试),往里加 pi 专有断言会把共用测试也弄分叉 —— 与 `lib/` 同一条规矩。

**判据含结构断言 + 行为断言**,并已按纪律先验区分力:
· 变异 1(把 `piMailFallback` 塞回共用模块 = 本次分叉的形状)⇒ 结构判据**变红**;
· 变异 2(把 `.pi` 改成 `.dsh`)⇒ 三条行为判据**变红**;
两次变异都用 `cp` 恢复并以 `cmp` 校验一致。

★ 顺带记下 pi 指出的一条:`check-deploy-drift.mjs` 的判据 ① 是我扩到"比全部 133 个文件"的,
所以这次分叉**它能抓到** —— 但 `check-shared-libs` 先红了,说明两道门的分工是对的。
This commit is contained in:
2026-09-15 06:31:18 +08:00
parent 7f03ee7ca2
commit 011957ac97
5 changed files with 133 additions and 17 deletions

View File

@ -13,21 +13,6 @@ import { existsSync, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
/**
* 没有可用 `to_workspace` 时的兜底目录(pi 专用)。
*
* 命名与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在 lib/ 下
* (三平台逐字节相同)写死了 `.dsh`,不能为 pi 改 —— pi 的会话落进 `~/.dsh/`
* 会让人以为是 DSH 在干活。
*
* 放在这里而不是 worker 里:**父进程要用同一个兜底值**。worker 的沙箱 rw 列表
* 由父进程算(`pool.mjs` → `lib/sandbox.js`),父进程猜一个目录而 worker 落在另一个,
* 症状是"界内也写不了"—— 为此把兜底收成一处,两边 import 同一个函数。
*/
export function piMailFallback(sessionKey) {
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
*

View File

@ -0,0 +1,53 @@
/**
* pi 平台专有的路径常量。
*
* # 为什么这个文件在 `src/` 而不在 `lib/`
*
* 按 `docs/PLUGIN-CONTRACT.md` 第六节,`lib/` 是**候选共用**目录:里面的文件在四个桥里
* **逐字节相同**,由 `deploy/check-shared-libs.sh` 校验并纳入 `install.sh` 门禁。
* 而本文件的内容**按定义就是平台专有的**(目录名写死 `.pi`),
* 所以它不能住 `lib/` —— 否则下一个人会问"它为什么不在 `ALL_LIBS` 里"。
*
* # 这一搬是有来历的(别再搬回去)
*
* `piMailFallback` 原先定义在共用的 `lib/workspace.js` 里,于是 pi 那一份与
* opencode / dsh / zcode 三份**不再逐字节相同**,`check-shared-libs.sh` 变红:
*
* 共用模块已分叉:lib/workspace.js(plugins/opencode-mail-bridge vs plugins/pi-mail-bridge)
*
* 契约的逃逸口**不是豁免清单**,而是共用函数 `resolveWorkspaceCwd(workspace, fallback)`
* 的**第二个参数** —— 平台兜底值本来就该由平台侧传进去。四个桥对照一下就很清楚:
*
* | 桥 | 平台兜底值在哪 | 怎么交给共用函数 |
* |----------|---------------------------------------------|--------------------|
* | opencode | 自己的 `index.js` | 传 `directory` |
* | zcode | 自己的 `src/index.mjs`(`zcodeSessionFallback`) | 传进去 |
* | dsh | 共用的 `mailSessionFallback`(写死 `.dsh`) | 直接用 |
* | pi | **这里** | 传进去 |
*
* ⇒ `doc 1150` 不用改、也不该加旁路:pi 只是唯一一个把平台值**造在共用模块里**的,
* 把它挪回平台侧就恢复了规矩。(共用 `workspace.js` 里那个 `.dsh` 常量仍是它保真的
* 唯一一份,与本节不矛盾:规矩保的是"**只有一份**",不是"不许有平台数据"。)
*
* # 为什么父子共用这一个文件
*
* `pool.mjs`(父)与 `worker.mjs`(子)**必须算出同一个兜底目录**:
* worker 的沙箱 `rw` 清单由父进程算(`lib/sandbox.js`),
* 父进程猜一个目录而 worker 落在另一个,症状是"**界内也写不了**"。
* 所以兜底值收成这一处,两边 import 同一个函数。
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* 没有可用 `to_workspace` 时的兜底目录(pi 专用)。
*
* 目录名是 `.pi` 而不是 `.dsh`:pi 的会话落进 `~/.dsh/` 会让人以为是 DSH 在干活。
*
* @param {string|undefined} sessionKey 会话标识;空值收敛成 `default`
* @returns {string} `~/.pi/mail-sessions/<key>`
*/
export function piMailFallback(sessionKey) {
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
}

View File

@ -74,7 +74,8 @@ import { fileURLToPath } from 'node:url';
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
import { workerLaunch } from '../lib/sandbox.js';
import { piMailFallback, resolveWorkspaceCwd } from '../lib/workspace.js';
import { resolveWorkspaceCwd } from '../lib/workspace.js';
import { piMailFallback } from './paths.mjs';
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));

View File

@ -49,7 +49,8 @@ import { createMailTools } from './tools.mjs';
import { openSession, runTurn } from './session-pool.mjs';
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError, renderResumeFailure } from './turn.mjs';
import { planNamingSync, planWriteBack } from './naming.mjs';
import { piMailFallback, resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
import { piMailFallback } from './paths.mjs';
import { modelAttemptOrder, renderFailureReport } from '../lib/model-scope.js';
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
import { autoRelayDecision } from '../lib/relay-policy.js';

View File

@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { piMailFallback } from '../src/paths.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const HOME = homedir();
/**
* 这一组判据补的是一个**不对称**(pi 评审 2026-09-15):
* 当初把 `piMailFallback` 造在共用的 `lib/workspace.js` 里时,
* **四个桥的 `npm test` 全绿,只有 `check-shared-libs.sh` 抓得到**
* —— 因为 `piMailFallback` 一条测试都没有,没有任何东西读它。
*
* 为什么这个测试文件是**独立**的(不放进 `test/workspace.test.mjs`):
* 那个文件在四个桥里**逐字节相同**(md5 一致,属于共用测试),
* 往里加 pi 专有的断言会把共用测试也弄分叉 —— 与 `lib/` 是同一条规矩。
*/
test('★ piMailFallback 落在 ~/.pi/mail-sessions/<key>(不是 .dsh)', () => {
// 目录名必须是 `.pi`:落进 `~/.dsh/` 会让人以为是 DSH 在干活。
const p = piMailFallback('abc-123');
assert.equal(p, join(HOME, '.pi', 'mail-sessions', 'abc-123'));
// 反向断言:绝不能带 `.dsh`(这正是当初要分叉出来的原因,写死它防回归)
assert.ok(!p.includes('.dsh'), `兜底目录不该落在 dsh 名下:${p}`);
});
test('空 key 收敛成 default(而不是 undefined/literal 空串)', () => {
// 缺失 session_id 时不能拼出 `.../undefined` 或 `.../`:
// 那会让所有缺 key 的会话挤进同一个目录,且目录名像个 bug。
assert.equal(piMailFallback(undefined), join(HOME, '.pi', 'mail-sessions', 'default'));
assert.equal(piMailFallback(''), join(HOME, '.pi', 'mail-sessions', 'default'));
assert.equal(piMailFallback(null), join(HOME, '.pi', 'mail-sessions', 'default'));
});
test('数字/非字符串 key 也收敛成字符串(不产生 NaN 路径)', () => {
assert.equal(piMailFallback(42), join(HOME, '.pi', 'mail-sessions', '42'));
});
test('★ 结构判据:平台兜底值住在 src/,不在共用的 lib/ 里', () => {
// 这是这次分叉的**结构性**回归判据 —— 光测行为测不出"它住在哪"。
const pathsSrc = readFileSync(join(ROOT, 'src', 'paths.mjs'), 'utf8');
assert.match(pathsSrc, /export function piMailFallback/, '兜底函数应当在 src/paths.mjs');
const sharedWorkspace = readFileSync(join(ROOT, 'lib', 'workspace.js'), 'utf8');
assert.ok(!sharedWorkspace.includes('piMailFallback'),
'共用 lib/workspace.js 里不该再有平台专有的 piMailFallback(它就是分叉的原因)');
assert.ok(!sharedWorkspace.includes("'.pi'"),
'共用模块里不该出现 pi 的目录名常量');
// `.dsh` 仍留在共用模块里是**对的**:那是 dsh 自己的兜底值,
// 也是共用模块保真的"唯一一份"。规矩保的是"只有一份",不是"不许有平台数据"。
assert.ok(sharedWorkspace.includes('mailSessionFallback'),
'共用模块里 dsh 的兜底函数应当保留(它不是 pi 的,不该跟着搬)');
});
test('★ 父子同源:pool 与 worker 从同一个模块取兜底值', () => {
// 为什么这条要紧:worker 的沙箱 rw 清单由**父进程**(pool.mjs)算。
// 父进程猜一个目录、worker 落在另一个 ⇒ 症状是"界内也写不了"。
// 所以两个文件必须 import 同一个函数,而不是各自拼一份。
for (const f of ['pool.mjs', 'worker.mjs']) {
const src = readFileSync(join(ROOT, 'src', f), 'utf8');
assert.match(src, /import \{ piMailFallback \} from '\.\/paths\.mjs'/,
`${f} 应当从 ./paths.mjs import piMailFallback`);
// 反向:不能再从共用的 lib/workspace.js import 它
const workspaceImport = src.split('\n').find((l) => l.startsWith('import') && l.includes('workspace.js'));
assert.ok(workspaceImport !== undefined, `${f} 仍应从 lib/workspace.js import 别的函数`);
assert.ok(!workspaceImport.includes('piMailFallback'),
`${f} 不该再从共用模块 import piMailFallback:${workspaceImport}`);
}
});