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。
This commit is contained in:
@ -69,6 +69,7 @@
|
||||
|
||||
// ★ 起名 spawnProcess:本文件里已经有一个 `function spawn(job)`,直接叫 spawn 会
|
||||
// 自己调自己(实测踩到:RangeError: Maximum call stack size exceeded)。
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawn as spawnProcess } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@ -76,6 +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';
|
||||
|
||||
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
|
||||
|
||||
@ -196,13 +198,29 @@ export function createWorkerPool({
|
||||
// (`grouped` = path 位是一个已存在的目录),所以 N-2「笔误不落真目录」不受影响:
|
||||
// path 位给了但不存在 ⇒ `resolveWorkspaceCwd` 返回兜底 + grouped=false,
|
||||
// 于是建的是**兜底目录**,笔误路径永远不会被创建。
|
||||
// ★ "这一轮用哪个 cwd"收成**一处决定**(pi 评审 2026-09-15 的探针实测):
|
||||
// 原先父进程只按**这封信的地址**算 rw,而 worker 复用会话时用的是
|
||||
// **上次实际用过的 cwd**(`job.session.cwd`)—— 会话键只看 session_id,
|
||||
// 所以地址一换形状(`pi@/some/dir` → `pi@.<会话>`)两者就分叉,
|
||||
// 症状是"**界内也写不了**"(rw 里没有 worker 真正要写的那个目录)。
|
||||
// 现在两侧都从这个纯函数取值,并把它注入 job(worker 消费,不再自己推导)。
|
||||
const sessionReused = Boolean(state.sessionFile && state.cwd && existsSync(state.sessionFile));
|
||||
const turnCwd = resolveTurnCwd({
|
||||
sessionReused,
|
||||
storedCwd: state.cwd,
|
||||
toWorkspace: job.data?.to_workspace,
|
||||
sessionKey: job.data?.session_id,
|
||||
fallback: piMailFallback,
|
||||
resolve: resolveWorkspaceCwd,
|
||||
});
|
||||
let launch;
|
||||
try {
|
||||
const ws = resolveWorkspaceCwd(job.data?.to_workspace, piMailFallback(job.data?.session_id));
|
||||
ensureCwd(ws.cwd, ws.grouped);
|
||||
// 复用路径的 cwd 本来就必须存在(是 worker 上次实际用的);只有按地址算出来的
|
||||
// 才需要先建(否则 `workerLaunch` 会因为 `!exists(cwd)` 不套沙箱 —— 见 99e6560)。
|
||||
if (turnCwd.source === 'address') ensureCwd(turnCwd.cwd, turnCwd.grouped);
|
||||
launch = workerLaunch({
|
||||
mode: job.data?.permission_mode || 'workspace',
|
||||
cwd: ws.cwd,
|
||||
cwd: turnCwd.cwd,
|
||||
nodePath: process.execPath,
|
||||
workerPath,
|
||||
// 注入点:判据不许依赖"这台机器装没装 /opt/agentmail/bin/am-sandbox"。
|
||||
@ -296,6 +314,9 @@ export function createWorkerPool({
|
||||
session: {
|
||||
sessionFile: state.sessionFile || '',
|
||||
cwd: state.cwd || '',
|
||||
// 父进程已经算好的"这一轮用哪个 cwd"。worker **消费**它而不是自己重新推导 ——
|
||||
// 这是"三来源变一来源"的第一步(残余的接管路径见 src/turn-cwd.mjs 的头注释)。
|
||||
resolvedCwd: turnCwd.cwd,
|
||||
},
|
||||
grants: [...state.grants],
|
||||
lastSyncedName: state.lastSyncedName || '',
|
||||
|
||||
78
plugins/pi-mail-bridge/src/turn-cwd.mjs
Normal file
78
plugins/pi-mail-bridge/src/turn-cwd.mjs
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 决定"**这一轮 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' };
|
||||
}
|
||||
@ -337,7 +337,11 @@ async function loadSession(mailTools) {
|
||||
const given = job.session?.sessionFile;
|
||||
|
||||
if (given && existsSync(given)) {
|
||||
const cwd = job.session.cwd || resolveWorkspaceCwd(
|
||||
// ★ 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({
|
||||
cwd, modelRuntime, customTools: mailTools,
|
||||
@ -385,11 +389,18 @@ async function loadSession(mailTools) {
|
||||
if (!grouped && data.to_workspace) {
|
||||
log(`工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
|
||||
}
|
||||
ensureCwd(cwd, grouped);
|
||||
// ★ 与父进程对齐:父进程已经按**同一个纯函数**算过这一轮的 cwd 并据此算了
|
||||
// `--rw`。若两者不一致(理论上不该),取父进程那个 —— 因为 rw 是按它算的,
|
||||
// 用别的值就是"界内也写不了"。父进程没给时才用本地推导的结果。
|
||||
const turnCwd = job.session?.resolvedCwd || cwd;
|
||||
if (job.session?.resolvedCwd && job.session.resolvedCwd !== cwd) {
|
||||
log(`注意:父进程定的 cwd=${job.session.resolvedCwd} 与本地推导的 ${cwd} 不同,按父进程的走`);
|
||||
}
|
||||
ensureCwd(turnCwd, grouped || Boolean(job.session?.resolvedCwd));
|
||||
const opened = await openSession({
|
||||
cwd, modelRuntime, customTools: mailTools, extension: permissionExtension(),
|
||||
cwd: turnCwd, modelRuntime, customTools: mailTools, extension: permissionExtension(),
|
||||
});
|
||||
log(`新建 pi 会话 ${opened.session.sessionId}(cwd=${cwd})`);
|
||||
log(`新建 pi 会话 ${opened.session.sessionId}(cwd=${turnCwd})`);
|
||||
return { ...opened, cwd, reused: false };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user