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 处待部署(与先前一致 —— 本轮只改已有文件,未新增文件)。
This commit is contained in:
2026-09-15 06:53:53 +08:00
parent f8fe14d124
commit 0f7c817c1e
4 changed files with 125 additions and 9 deletions

View File

@ -77,7 +77,7 @@ import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'
import { workerLaunch } from '../lib/sandbox.js';
import { ensureCwd, resolveWorkspaceCwd } from '../lib/workspace.js';
import { piMailFallback } from './paths.mjs';
import { resolveTurnCwd } from './turn-cwd.mjs';
import { resolveSessionReuse, resolveTurnCwd } from './turn-cwd.mjs';
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
@ -204,7 +204,13 @@ export function createWorkerPool({
// 所以地址一换形状(`pi@/some/dir` → `pi@.<会话>`)两者就分叉,
// 症状是"**界内也写不了**"(rw 里没有 worker 真正要写的那个目录)。
// 现在两侧都从这个纯函数取值,并把它注入 job(worker 消费,不再自己推导)。
const sessionReused = Boolean(state.sessionFile && state.cwd && existsSync(state.sessionFile));
// ★ 复用判定**只有这一处实现**(`resolveSessionReuse`)—— 原先 pool 与 worker 各写一份,
// 两边一不一致,`resolvedCwd` 就可能被交给一个不消费它的分支(pi 评审 §三)。
const { reused: sessionReused } = resolveSessionReuse({
sessionFile: state.sessionFile,
storedCwd: state.cwd,
exists: existsSync,
});
const turnCwd = resolveTurnCwd({
sessionReused,
storedCwd: state.cwd,
@ -317,6 +323,8 @@ export function createWorkerPool({
// 父进程已经算好的"这一轮用哪个 cwd"。worker **消费**它而不是自己重新推导 ——
// 这是"三来源变一来源"的第一步(残余的接管路径见 src/turn-cwd.mjs 的头注释)。
resolvedCwd: turnCwd.cwd,
// 一并把**判定**交给 worker:worker 不再自己 existsSync 一份(否则又是两种判定)
sessionReused,
},
grants: [...state.grants],
lastSyncedName: state.lastSyncedName || '',

View File

@ -49,6 +49,34 @@
* 在那之前,接管路径的错位没有任何一层提示,已记入 `docs/DEBTS.json`。
*/
/**
* **复用判定**(唯一一处实现)。
*
* # 为什么它也必须收成一处(pi 评审 2026-09-15 §三)
*
* 原先父进程与 worker **各写了一份**判定:
*
* pool : `state.sessionFile && state.cwd && existsSync(state.sessionFile)`
* worker : `given && existsSync(given)`
*
* 这正是前两轮刚花力气消掉的那种"两处各写一份",而且它是 `resolvedCwd` **交错生效的前提**:
* 两边判定一不一致,`resolvedCwd` 就可能被交给一个**不会消费它的分支**。
* 前面那个"新建分支回报了没用的 cwd"能出问题,根子就在这儿。
*
* `exists` 注入而不是直接调 `existsSync`:这样判据能在不碰真磁盘的情况下
* 覆盖"文件在/不在"两种情形,也不会因为判据自己造文件而改变被测状态。
*
* @param {object} o
* @param {string} [o.sessionFile] 上次这条会话用的文件(父进程从 `state` 拿,worker 从 `job.session` 拿)
* @param {string} [o.storedCwd] 上次实际用的 cwd
* @param {(p: string) => boolean} o.exists
* @returns {{reused: boolean, reuseFile: string}} `reuseFile` 只在 `reused` 时为非空
*/
export function resolveSessionReuse({ sessionFile, storedCwd, exists }) {
const reuseFile = sessionFile && storedCwd && exists(sessionFile) ? String(sessionFile) : '';
return { reused: Boolean(reuseFile), reuseFile };
}
/**
* 这一轮该用哪个 cwd。
*
@ -73,6 +101,8 @@ export function resolveTurnCwd({
if (sessionReused && storedCwd) {
return { cwd: String(storedCwd), grouped: true, source: 'reused' };
}
// `kind`(`new` / `adopt`)与 cwd 无关:接管路径的 cwd 由**会话文件 header** 决定,
// 那是 worker 才知道的信息(父进程读 header 的代价见头注释的"残余")。
const { cwd, grouped } = resolve(toWorkspace, fallback(sessionKey));
return { cwd, grouped, source: 'address' };
}

View File

@ -336,11 +336,20 @@ async function loadSession(mailTools) {
const data = job.data;
const given = job.session?.sessionFile;
if (given && existsSync(given)) {
// ★ cwd 优先取**父进程已经算好的那个**(`job.session.resolvedCwd`)——
// 父进程用它算沙箱的 `--rw`,worker 必须用同一个值,否则 rw 里可能没有
// 我们真正要写的目录(症状"界内也写不了")。见 src/turn-cwd.mjs 头注释。
// 父进程没给(老协议/接管路径)时才退回自己推导。
// ★ **复用判定消费父进程的结论**,不再自己 `existsSync` 一份(pi 评审 §三):
// 父进程的判定与 worker 的判定各写一份时,`resolvedCwd` 就可能被交给一个
// 不消费它的分支 —— 那正是"回报了没用的 cwd"那个缺口的根子。
// 判定唯一的实现是 `src/turn-cwd.mjs` 的 `resolveSessionReuse`。
// 父进程没给(老协议)时才退回自己判一次(保底,不是常规路径)。
const decidedReused = job.session?.sessionReused;
const reuse = decidedReused === undefined
? { reused: Boolean(given && existsSync(given)) }
: { reused: Boolean(decidedReused) };
if (reuse.reused && given) {
// cwd 取**父进程已经算好的那个**(`job.session.resolvedCwd`)——
// 父进程用它算沙箱的 `--rw`,worker 必须用同一个值,否则 rw 里可能没有
// 我们真正要写的目录(症状"界内也写不了")。见 src/turn-cwd.mjs 头注释。
const cwd = job.session?.resolvedCwd || job.session.cwd || resolveWorkspaceCwd(
data.to_workspace, piMailFallback(data.session_id)).cwd;
const opened = await openSession({
@ -401,7 +410,13 @@ async function loadSession(mailTools) {
cwd: turnCwd, modelRuntime, customTools: mailTools, extension: permissionExtension(),
});
log(`新建 pi 会话 ${opened.session.sessionId}(cwd=${turnCwd})`);
return { ...opened, cwd, reused: false };
// ★ 回报的必须是**实际用的那个**(`turnCwd`),不是本地推导的 `cwd`:
// 这个返回值经 `session_opened` → `state.cwd`,而 `state.cwd` 正是下一轮
// `resolveTurnCwd` 的 `storedCwd`(也即下一轮 `--rw` 的输入)。
// 回报成 `cwd` 就等于让"记下来的必须是实际用的"这条不变量在这一支上不成立
// ⇒ 等式只在"本轮 rw vs 本轮 openSession"上闭合,没在"本轮 rw vs 下一轮 rw"上闭合
// (pi 评审 §三 抓到;起因是 `openSession` 用 `turnCwd` 而 `return` 用 `cwd`)。
return { ...opened, cwd: turnCwd, reused: false };
}
// ─── 命名一致(C-11 / W-7)───