Files
MailUI4Agents/plugins/pi-mail-bridge/src/turn-cwd.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

109 lines
6.2 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.

/**
* 决定"**这一轮 worker 用哪个 cwd**"—— 父子两侧的唯一来源。
*
* # 为什么需要这个模块(一次实测出来的分叉)
*
* worker 的沙箱 `--rw` 清单由**父进程**算(`pool.mjs` → `lib/sandbox.js`),
* 而 worker 实际用的 cwd 是它自己推的。两个来源一旦不一致,症状是
* **"界内也写不了"** —— 最难查的一类,因为 EACCES 落在"界内",
* 读日志的人会以为沙箱装错了。
*
* `pool.mjs` 里那句注释原本防的是**公式**分叉(父子都调 `resolveWorkspaceCwd`)。
* 但 cwd 有**两个来源**,所以只统一公式不够(pi 评审 2026-09-15 用探针实测):
*
* 父进程:`resolveWorkspaceCwd(to_workspace, …)` ← 来源:**这封信的地址**
* 子进程:`job.session.cwd || resolveWorkspaceCwd(…)` ← 来源:**这个会话上次实际用的 cwd**
*
* 而会话键 `keyOf(data) = data.session_id` —— **只看 session_id,不看 path 位**。
* 于是同一个 session_id 下地址换个形状(`pi@/some/dir` → `pi@.<会话>`,
* 或换成另一个已存在目录),父进程按**新地址**算 rw,worker 却**复用会话、
* 落在旧 cwd** 上 ⇒ rw 里没有 worker 真正要写的那个目录。
*
* 实测(探针,`exists` 注入):
*
* 父进程算出的 cwd = /root/.pi/mail-sessions/sess-x
* worker 实际会用 = /home/program/agentmail (= 这个会话的 state.cwd)
* sandboxed = true, rw 含 worker 实际 cwd? = **false**
*
* 不是假想:本线程那条会话自上线起 5 次启动的 rw 全是 `/home/program/agentmail`,
* 那就是它的 `state.cwd`;此时只要来一封写给 `pi@.<会话>`(不带 path)的信就会踩到。
*
* # 这条与"首回合补目录"那次修复的关系(必须一起看)
*
* `pool.mjs` 先 `ensureCwd` 再算 launch 的修复(commit `99e6560`)在**首回合**这个目标上
* 是净胜,但它在"地址换形状"这个组合上**改变了失败方式**:
*
* · 修复前:兜底目录不存在 ⇒ 不套沙箱 ⇒ `ask`(有人应答时**写得进去**)
* · 修复后:目录被建出来 ⇒ **套上沙箱,而 rw 是地址算的那个** ⇒ worker 在会话自己的
* cwd 里写 ⇒ **EACCES,且没有"问一次"这条路**(内核拒的,不是策略问的)
*
* ⇒ 两处必须一起对齐,否则第一处会把一个"多问一次"变成"界内也写不了"。
*
* # 残余(已知,未修)
*
* **接管会话**(`adoptID`)那条路上,worker 用的是**会话文件 header 里的** `info.cwd`
* (`worker.mjs` 的接管分支),父进程读不到 ⇒ 仍会错位。父进程要用 `session-scan.mjs`
* 才能拿到它,而那个模块的 `readHeader` 未导出、整表 `scan()` 在父进程里代价大
* (worker 里实测 1431ms / 240MB)。彻底的方向是**把"这次用哪个 cwd"收成父进程一处决定**
* (它有 `state.sessionFile`,header 也能读),worker 只消费、不再自己推导 —— 三来源变一来源。
* 在那之前,接管路径的错位没有任何一层提示,已记入 `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。
*
* 输入**全部来自 public 状态**(父进程也拿得到),所以两侧算出的一定是同一个值。
*
* @param {object} o
* @param {boolean} o.sessionReused 这次是否复用已有会话(= `job.session.sessionFile` 存在且文件在)
* @param {string} [o.storedCwd] 复用时这个会话**上次实际用的** cwd(`state.cwd`)
* @param {string} [o.toWorkspace] 这封信地址里的 path 位
* @param {string|number} [o.sessionKey] 会话标识(兜底目录用)
* @param {(key: string|number|undefined) => string} o.fallback 平台兜底目录函数
* @param {(workspace: string|undefined, fallback: string) => {cwd: string, grouped: boolean}} o.resolve
* `resolveWorkspaceCwd` —— 注入是为了可测且不重复实现寻址规则
* @returns {{cwd: string, grouped: boolean, source: 'reused'|'address'}}
* `source` 是给日志/判据用的:能一眼看出这一轮 cwd 是**复用**来的还是**按地址**算的
*/
export function resolveTurnCwd({
sessionReused, storedCwd, toWorkspace, sessionKey, fallback, resolve,
}) {
// 与 worker 同一条规则:复用会话时,**上次实际用的 cwd 优先**。
// 这正是父进程原先漏掉的那一支 —— 父进程只看了地址。
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' };
}