Files
MailUI4Agents/plugins/pi-mail-bridge/src/turn-cwd.mjs
JianFeeeee 7fe279676a fix(pi-bridge)!: --rw 的 cwd 与 worker 实际用的 cwd 收成**一处决定**(pi 探针实测的第三例)
pi 2026-09-15 报、我用探针复核**成立**,而且它把 `99e6560` 的代价也一起说清了。

**分叉在哪**:cwd 有**两个来源**,而会话键 `keyOf(data) = data.session_id` **只看 session_id**:

```
父进程(算 --rw)  cwd = resolveWorkspaceCwd(to_workspace, …)   ← 来源:**这封信的地址**
子进程(真去干活)  cwd = job.session.cwd || resolveWorkspaceCwd(…) ← 来源:**会话上次实际用的 cwd**
```

于是同一 session_id 下地址换个形状(`pi@/some/dir` → `pi@.<会话>`),父进程按**新地址**
算 rw,worker 却**复用会话、落在旧 cwd**。实测(真函数,`exists` 注入):

```
父进程算的 cwd  = /root/.pi/mail-sessions/sess-x
worker 实际会用 = /home/program/agentmail   (= 该会话的 state.cwd)
rw 含 worker 实际 cwd? = false  ⇒ 界内 EACCES
```

不是假想:本线程那条会话自上线起每次启动的 rw 都是 `/home/program/agentmail`,
那就是它的 `state.cwd` —— 此时来一封 `pi@.<会话>`(不带 path)的信就会踩到。

**★ `99e6560` 在这个组合上把失败方式变坏了**(这条必须记下来,我原先只报了它的好处):
· 之前:兜底目录不存在 ⇒ 不套沙箱 ⇒ `ask`(有人应答时**写得进去**)
· 之后:目录被建出来(那次修复的效果)⇒ **套上沙箱,而 rw 是地址算的那个**
  ⇒ worker 在会话自己的 cwd 里写 ⇒ **EACCES,且没有"问一次"这条路**(内核拒的)
我用 `ensureCwd` 前/后各跑一次验证了这条因果,实测 `(b) 建了兜底目录: sandboxed = true,
rw 含 worker 实际 cwd? = false` —— 与 pi 报的 `sandboxed=true` 一字不差(他给了那个值,
我最初复现成 false,差别就在"兜底目录建没建",属于应用 `99e6560` 前后)。
⇒ **两处修复必须一起部署**,否则中间态是"界内也写不了"(比原先多问一次更糟)。
现在两次提交都在仓库、`drift` 报 5 处待部署,会一起上线。

**修法**:新增 `src/turn-cwd.mjs` 的纯函数 `resolveTurnCwd()` —— 输入全部来自**父进程也拿得到的
public 状态**(`sessionReused` / `storedCwd` / `toWorkspace` / `sessionKey` / 注入的解析函数),
父子两侧都从它取值;父进程再把决定**注入 job**(`session.resolvedCwd`),worker **消费**它、
不再自己推导。于是"三来源变一来源"落了第一步。

**判据(pi 要的那条等式)**:
· ★**等式**:用真 `sandboxWritePaths` 算 rw,断言"**`--rw` 里的 cwd === worker 会用的 cwd**";
  并附**反面对照**:按地址算出来的 rw **不含** `state.cwd`(= 修复前的错位状态)。
· 复用/非复用/`storedCwd` 为空三种输入各一条。
· 结构:pool 必须把决定交给 `workerLaunch` **并**注入 job;worker 取 cwd 的**每一处表达式**
  都必须先看 `resolvedCwd`。

★ **两处我自己的判据缺陷,都是变异测出来的,都记在测试文件里**:
1. 上一轮我在 `sandbox-launch.test.mjs` 写的 `assert.match(src, /resolveWorkspaceCwd\(/)`
   **本来就不该红也不该绿** —— 它护的是**写法**(池子直接调那个函数),而引入 `resolveTurnCwd`
   后池子改成"当参数传进去"(更对),它才红。**红得对**:它当初断言的是实现细节,
   不是它想要的性质。已改为断言性质(解析函数必须来自共用模块、且被显式传给纯函数)。
   顺带说明:它此前一直是**假绿**还是**真绿**我没法回测,但**它在引入纯函数后才红**说明它
   确实绑定了写法 —— 这正是"判据的适用范围没写出来"那一类。
2. 新版 worker 侧结构判据**第一版不具区分力**:只断言"文件里出现 `resolvedCwd`",
   把消费那一支删掉、退回 `job.session.cwd`,正则**仍然匹配**(别处还留着它)⇒ 变异后依旧全绿。
   已改为断言**优先级**(取 cwd 的表达式必须含 `resolvedCwd`)。
   ★ 变异实测:修好后重做同一变异 ⇒ 判据**变红**;两次变异均 `cp` 恢复 + `cmp` 校验。

**残余(未修,已进 DEBTS)**:**接管会话**那条路 worker 用会话文件 header 里的 `info.cwd`,
父进程读不到 ⇒ 首回合仍可能错位。父进程要拿它得用 `session-scan.mjs`,而 `readHeader` 未导出、
整表 `scan()` 在父进程里代价大(worker 里实测 1431ms / 240MB)。
彻底方向即 pi 说的:把"这次用哪个 cwd"完全收成父进程一处决定,worker 只消费。现在做不做等定。

验证:pi 桥 **502/502**;另三个桥 fail 0;`check-shared-libs` exit 0;`install.sh --check` exit 0。
2026-09-15 06:47:10 +08:00

79 lines
4.6 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`。
*/
/**
* 这一轮该用哪个 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' };
}
const { cwd, grouped } = resolve(toWorkspace, fallback(sessionKey));
return { cwd, grouped, source: 'address' };
}