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 处待部署(与先前一致 —— 本轮只改已有文件,未新增文件)。
109 lines
6.2 KiB
JavaScript
109 lines
6.2 KiB
JavaScript
/**
|
||
* 决定"**这一轮 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' };
|
||
}
|