From 0f7c817c1e97e4a5a7dcf8f92b862dc90b3e4859 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 06:53:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi-bridge):=20=E5=9B=9E=E6=8A=A5=E7=9A=84?= =?UTF-8?q?=20cwd=20=E5=BF=85=E9=A1=BB=E6=98=AF=E5=AE=9E=E9=99=85=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=AA=20+=20=E5=A4=8D=E7=94=A8=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E6=94=B6=E6=88=90=E4=B8=80=E5=A4=84=EF=BC=88pi=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20=C2=A7=E4=B8=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 处待部署(与先前一致 —— 本轮只改已有文件,未新增文件)。 --- plugins/pi-mail-bridge/src/pool.mjs | 12 +++- plugins/pi-mail-bridge/src/turn-cwd.mjs | 30 +++++++++ plugins/pi-mail-bridge/src/worker.mjs | 27 ++++++-- plugins/pi-mail-bridge/test/turn-cwd.test.mjs | 65 ++++++++++++++++++- 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index 202cfd0..1764521 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -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 || '', diff --git a/plugins/pi-mail-bridge/src/turn-cwd.mjs b/plugins/pi-mail-bridge/src/turn-cwd.mjs index fc1d307..b0f55c5 100644 --- a/plugins/pi-mail-bridge/src/turn-cwd.mjs +++ b/plugins/pi-mail-bridge/src/turn-cwd.mjs @@ -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' }; } diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index c6d86fd..1bc4dca 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -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)─── diff --git a/plugins/pi-mail-bridge/test/turn-cwd.test.mjs b/plugins/pi-mail-bridge/test/turn-cwd.test.mjs index 966dd7d..e7b8912 100644 --- a/plugins/pi-mail-bridge/test/turn-cwd.test.mjs +++ b/plugins/pi-mail-bridge/test/turn-cwd.test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { resolveTurnCwd } from '../src/turn-cwd.mjs'; +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'; @@ -98,3 +98,66 @@ test('★ 结构判据:pool 算 launch 与 worker 取值必须来自同一个 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, + '没有文件不算复用'); +});