Files
MailUI4Agents/plugins/pi-mail-bridge/test/turn-cwd.test.mjs
JianFeeeee 0f7c817c1e fix(pi-bridge): 回报的 cwd 必须是实际用的那个 + 复用判定收成一处(pi 评审 §三)
pi 2026-09-15 §三 报的两条,我都逐行核了,**都成立**。

## 一、新建分支回报的 cwd ≠ 它实际用的 cwd(他给的最小修法)

```js
const opened = await openSession({ cwd: turnCwd, … });   // ← 用的是 turnCwd
return { ...opened, cwd, reused: false };                // ← 回报的是本地推导的 cwd
```

这个返回值经 `session_opened` → `state.cwd`,而 `state.cwd` **正是下一轮
`resolveTurnCwd` 的 `storedCwd`**(也即下一轮 `--rw` 的输入)。
⇒ `7fe2796` 建立的那条"**记下来的必须是实际用的**"不变量在这一支上不成立:
等式只在"本轮 rw vs 本轮 openSession"上闭合,**没在"本轮 rw vs 下一轮 rw"上闭合**。
改成 `return { ...opened, cwd: turnCwd, reused: false }`。

★ 可达性我说实话:**窄**。要 `resolvedCwd !== 本地 cwd` 得"父进程判复用而 worker 落到
新建分支",目前只有"父进程判完之后会话文件消失"这条 TOCTOU 窗口能造出来。
所以它现在**不是 bug,是一条会随别人改动而变成 bug 的不变量缺口** —— pi 的定性准确,
我照他的定性记,不夸大。

## 二、复用判定两处各写一份(结构性,而且是上面那条的前提)

```
pool   : state.sessionFile && state.cwd && existsSync(state.sessionFile)
worker : given && existsSync(given)
```

这正是前两轮刚消掉的那种"两处各写一份",而且它决定了 `resolvedCwd` 会不会被交给
一个**不消费它的分支** —— 上面那条能出问题,根子在这儿。
新增 `src/turn-cwd.mjs` 的 `resolveSessionReuse({sessionFile, storedCwd, exists})`
(**唯一一处实现**,`exists` 注入以便判据覆盖"在/不在"两种情形),
pool 用它判、并把结论一并注入 job(`session.sessionReused`),worker **消费**它。

## 三、判据(pi 建议的两条,都做了,且都验过区分力)

1. **等式/配对**:按分支回溯 —— 以每个 `return { ...opened, cwd:? X, reused` 为锚,
   回溯它前面最近的 `openSession(`,断言**同一个符号**。
   ★ 这条我**写坏过两次**,两次都是变异测出来的,都记在测试文件里:
   · 第一版用两串正则分别抓,`matchAll` 的懒惰量词**只抓到各一个**,
     而"只有一个"时包含关系天然成立 ⇒ 变异后照样全绿;
   · 第二版修好配对后,`[\w.?]+` 要求**至少一个字符** ⇒ 抓不到简写 `cwd,`
     (实际三处里两处是简写)⇒ 报"应当抓到三处,实际 1"。
   ⇒ **判据红了要查清是产线错了还是判据错了**;这两次都是判据错,不是产线错。
2. **单点**:`resolveSessionReuse` 必须是唯一实现;pool 必须用它;worker 必须消费
   `job.session.sessionReused`,且只在它缺失时才退回自己判。
3. 另加 `resolveSessionReuse` 四种输入组合(文件在/不在 × cwd 有/无)。

**变异实测(三条,均 `cp` 恢复 + `cmp` 校验)**:
· 把回报改回 `cwd`(= pi 报的那个 bug)⇒ 配对判据**变红**;
· worker 又自己判一份(`decidedReused = undefined`)⇒ 单点判据**变红**;
· pool 绕回两处各写一份 ⇒ 单点判据**变红**。

## 四、一处我要标出来的(结构上被保留、实际不可达的分支)

worker 里那条保底分支 `decidedReused === undefined ? 自己判 : 消费父进程的`
**实际上走不到**:`sessionReused` 为真要求 `state.sessionFile && state.cwd`,
而这两个字段只在 `session_opened` 里被**一起**写入 ⇒ 有 `sessionReused` 就必有 `sessionFile`。
保留它是为了老协议/异常帧不至于静默落到"新建会话"(比报错更糟),
但它**没有判据覆盖**,也没法用真协议触发 —— 按"跑不到的分支"记账,不假装它被验过。

验证:pi 桥 **505/505**(+3);`check-shared-libs` exit 0;`install.sh --check` exit 0;
`drift` 报 5 处待部署(与先前一致 —— 本轮只改已有文件,未新增文件)。
2026-09-15 06:53:53 +08:00

164 lines
9.7 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.

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 { resolveSessionReuse, resolveTurnCwd } from '../src/turn-cwd.mjs';
import { piMailFallback } from '../src/paths.mjs';
import { resolveWorkspaceCwd } from '../lib/workspace.js';
import { sandboxWritePaths } from '../lib/sandbox.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const HOME = homedir();
const call = (o) => resolveTurnCwd({
fallback: piMailFallback, resolve: resolveWorkspaceCwd, ...o,
});
/**
* 这一组补的是 pi 评审 2026-09-15 用探针实测出来的**输入**分叉:
*
* 父进程:`resolveWorkspaceCwd(to_workspace, …)` ← 来源:**这封信的地址**
* 子进程:`job.session.cwd || resolveWorkspaceCwd(…)` ← 来源:**会话上次实际用的 cwd**
*
* 原先那条"父子同源"判据断言的是**函数**同源(两边 import 同一个 `piMailFallback`),
* 它**测不出输入分叉** —— 所以这里改成断言一条**等式**:
* "`--rw` 的 cwd(父进程算) === worker 会用的 cwd"。
*/
test('★ 复用会话时:cwd 取 state.cwd,而不是按这封信的地址重算', () => {
// 实测场景:会话 d042cc4c 的 state.cwd = /home/program/agentmail,
// 此时来一封写给 `pi@.<会话>`(不带 path)的信。
const r = call({
sessionReused: true,
storedCwd: '/home/program/agentmail',
toWorkspace: undefined, // 地址不带 path ⇒ 按地址算会得到兜底目录
sessionKey: 'sess-x',
});
assert.equal(r.cwd, '/home/program/agentmail',
'复用会话时必须用会话上次实际用的 cwd —— 按地址重算就是 rw 与 worker 分叉');
assert.equal(r.source, 'reused');
// 对照:按地址算会得到兜底目录,与 state.cwd 不同 ⇒ 如果不修,rw 就是错的
const byAddress = resolveWorkspaceCwd(undefined, piMailFallback('sess-x'));
assert.notEqual(byAddress.cwd, r.cwd, '两种来源确实不同名 —— 这正是分叉的成因');
});
test('★ 等式:--rw 里必须有 worker 会用的那个 cwd', () => {
// 这是 pi 要的那条可断言等式。用真 sandboxWritePaths 算 rw,
// 断言 worker 实际会用的 cwd **在 rw 里**。
const stored = '/home/program/agentmail';
const r = call({ sessionReused: true, storedCwd: stored, toWorkspace: undefined, sessionKey: 'sess-x' });
const exists = (p) => [stored, '/tmp', `${HOME}/.pi/agent`, `${HOME}/.pi/agent/sessions`,
`${HOME}/.agentmail-pi`, '/dev/null'].includes(p);
const { dirs } = sandboxWritePaths({ cwd: r.cwd, env: { TMPDIR: '/tmp', HOME }, exists, home: HOME });
assert.ok(dirs.includes(r.cwd),
`--rw 里必须有 worker 会用的 cwd(${r.cwd}):rw=${dirs.join(',')}`);
// 反面对照:如果不改(按地址算),rw 里就没有那个目录 —— 证明这条判据有区分力
const wrong = call({ sessionReused: false, toWorkspace: undefined, sessionKey: 'sess-x' });
const wrongDirs = sandboxWritePaths({ cwd: wrong.cwd, env: { TMPDIR: '/tmp', HOME }, exists, home: HOME }).dirs;
assert.ok(!wrongDirs.includes(stored),
'按地址算出来的 rw 不该含 state.cwd —— 这就是修复前的错位状态');
});
test('没有复用时仍按地址算(不能一律取 state.cwd)', () => {
const r = call({ sessionReused: false, storedCwd: '/home/program/agentmail', toWorkspace: '/home/program/agentmail', sessionKey: 'k' });
assert.equal(r.source, 'address', '没复用就该按地址算');
assert.equal(r.cwd, '/home/program/agentmail');
});
test('复用了但 state.cwd 为空 ⇒ 退回按地址算(老状态没有 cwd)', () => {
const r = call({ sessionReused: true, storedCwd: '', toWorkspace: '/tmp', sessionKey: 'k' });
assert.equal(r.source, 'address');
});
test('★ 结构判据:pool 算 launch 与 worker 取值必须来自同一个决定', () => {
const pool = readFileSync(join(ROOT, 'src', 'pool.mjs'), 'utf8');
assert.match(pool, /resolveTurnCwd\(/, 'pool 必须用这个纯函数决定 cwd');
assert.match(pool, /cwd: turnCwd\.cwd/, 'pool 必须把该决定交给 workerLaunch(--rw 按它算)');
assert.match(pool, /resolvedCwd: turnCwd\.cwd/, 'pool 必须把该决定注入 job 给 worker');
// worker 侧:★ 这条断言改过一版,因为**第一版不具区分力**(我自己变异测出来的)。
// 第一版只断言"文件里出现 `job.session?.resolvedCwd`" —— 把消费那一支删掉、
// 退回 `job.session.cwd`,那条正则**仍然匹配得上**(因为别处还留着它),
// 于是变异后测试依旧全绿 ⇒ 这条判据护不住它声称要护的东西。
// 现在断言的是**优先级**:每一处取 cwd 的表达式都必须先看 `resolvedCwd`。
const worker = readFileSync(join(ROOT, 'src', 'worker.mjs'), 'utf8');
const cwdExprs = [...worker.matchAll(/const cwd = ([^;]+);/g)].map((m) => m[1]);
assert.ok(cwdExprs.length >= 1, '应当至少有一处取 cwd 的表达式');
for (const expr of cwdExprs) {
assert.ok(expr.includes('resolvedCwd'),
`取 cwd 的表达式必须先看父进程的决定(resolvedCwd),实际是:${expr.trim()}`);
}
// 新建分支那处同样是"父进程优先"
assert.match(worker, /const turnCwd = job\.session\?\.resolvedCwd \|\| cwd/,
'新建分支也必须以父进程的决定为准');
});
test('★ 等式:每个分支 `openSession({cwd:X})` 与随后的 `return {…cwd:X}` 必须是同一个符号', () => {
// pi 评审 2026-09-15 §三 抓到的那一处:新建分支 `openSession` 用 `turnCwd`,
// 而 `return { ...opened, cwd, reused: false }` 回报的是本地推导的 `cwd`。
// 这个返回值经 `session_opened` → `state.cwd`,而 `state.cwd` 正是下一轮
// `resolveTurnCwd` 的 `storedCwd`(下一轮 `--rw` 的输入)⇒
// 等式只在"本轮 rw vs 本轮 openSession"上闭合,**没在"本轮 rw vs 下一轮 rw"上闭合**。
//
// 判据写成**配对**(同一手法:断言"同一个符号",而不是断言"出现过某个名字"——
// 后者正是我上一版判据不具区分力的原因)。
const worker = readFileSync(join(ROOT, 'src', 'worker.mjs'), 'utf8');
// ★ 第一版写法**不具区分力**(我又一次被自己的变异测出来):用两串正则分别抓
// `openSession` 的 cwd 与 `return` 的 cwd,结果因为 `matchAll` 的懒惰量词
// 只抓到**各一个**,而"只有一个"时包含关系天然成立 ⇒ 变异后照样全绿。
// 改为**按分支**配对:以每个 `return { ...opened, ... }` 为锚,回溯它前面最近的
// `openSession(` —— 这才是"这一支用了什么、又回报了什么"。
// ★ `\w+` 要求至少一个字符 ⇒ 抓不到简写 `cwd,`(实际三处里有两处是简写)。
// 量词写成 `*`:`cwd: X` 抓到 X,`cwd,` 抓到空串(= 简写,符号就是 cwd)。
const returns = [...worker.matchAll(/return \{ \.\.\.opened, cwd(?::\s*([\w.?]+))?\s*,\s*reused/g)];
assert.ok(returns.length >= 3, `应当抓到三处回报,实际 ${returns.length}`);
for (const m of returns) {
const before = worker.slice(0, m.index);
const openIdx = before.lastIndexOf('openSession(');
assert.ok(openIdx > 0, '每个回报前面应当有一个 openSession');
// 该 openSession 调用里取的 cwd 符号(`cwd` 或 `cwd: X`)
const call = worker.slice(openIdx, openIdx + 260);
const got = call.match(/cwd(?::\s*([\w.?]+))?\s*,/);
assert.ok(got, `解析不出 openSession 的 cwd:${call.slice(0, 80)}`);
const usedCwd = got[1] || 'cwd'; // `cwd,` 是简写 ⇒ 符号就是 cwd
const reported = m[1] || 'cwd'; // 简写 `cwd,` ⇒ 回报的符号就是 cwd
assert.equal(reported, usedCwd,
`回报的必须与 openSession 实际用的是同一个符号:用了 ${usedCwd},回报了 ${reported}`);
}
});
test('★ 单点:复用判定只有一处实现,worker 不再自己 existsSync 一份', () => {
// pi 评审 §三 的结构性那条:父进程与 worker 各写一份判定,是 `resolvedCwd`
// 交错生效的前提 —— 两边一不一致,resolvedCwd 就会被交给不消费它的分支。
const reuse = readFileSync(join(ROOT, 'src', 'turn-cwd.mjs'), 'utf8');
assert.match(reuse, /export function resolveSessionReuse/, '判定的唯一实现应当在 turn-cwd.mjs');
const pool = readFileSync(join(ROOT, 'src', 'pool.mjs'), 'utf8');
assert.match(pool, /resolveSessionReuse\(/, 'pool 必须用这个唯一的判定');
const worker = readFileSync(join(ROOT, 'src', 'worker.mjs'), 'utf8');
// worker 消费父进程的判定;只有在父进程没给(老协议)时才退回自己判
assert.match(worker, /const decidedReused = job\.session\?\.sessionReused/,
'worker 必须消费父进程的复用判定');
assert.match(worker, /decidedReused === undefined/, '并且只在这个判定缺失时才退回自己判');
});
test('resolveSessionReuse:文件在/不在、cwd 有无,四种组合', () => {
const exists = () => true;
const no = () => false;
assert.deepEqual(resolveSessionReuse({ sessionFile: '/f', storedCwd: '/c', exists }),
{ reused: true, reuseFile: '/f' });
assert.equal(resolveSessionReuse({ sessionFile: '/f', storedCwd: '', exists }).reused, false,
'没有 storedCwd 不算复用(父进程下一轮 --rw 就没有依据)');
assert.equal(resolveSessionReuse({ sessionFile: '/f', storedCwd: '/c', exists: no }).reused, false,
'文件不在不算复用');
assert.equal(resolveSessionReuse({ sessionFile: '', storedCwd: '/c', exists }).reused, false,
'没有文件不算复用');
});