fix(dsh)★★: resume 前不查 live ⇒ 每封来信撞自己的 flock,秒回「处理失败」

## 症状(用户报「秒回错误」)

每封来信立刻收到 `处理失败`,一封都没进去:

    会话 mail-515c0c70… 已在磁盘上(cwd=未记录),改为 resume 续谈
    建会话失败 llmsproxy/AUTO: session "mail-515c0c70…" is already owned
    by an active write handle

## 根因(三方证据)

**① 锁是 flock,且持有者就是 dsh 自己。** `lsof` 显示 pid 1547284(dsh 自身)
持有那个 `session.lock`;`fuser` 同结论。⇒ 不是残留锁、不是别的进程,是自己撞自己。

**② 机制**(`dsh-session-persistence-jsonl/lib/index.js:711`):

    await tryLockExclusive(handle.fd);
    if (isLockContention(error)) throw new SessionAlreadyOwnedError(id);

**③ 插件自己撞自己**:同一文件里两处调 `startAgent`——
  · 接管路径(约 1187 行)**早就有** `ctx.agents.get()` 去重,注释写明
    「同一条会话两个 handle 会各自往日志里写,replay 校验不过」
  · **普通投递路径漏了** ⇒ 会话在磁盘上时 `startAgent` 走 `ctx.agents.resume()`,
    它**再申请一次写 lease**,撞上本进程已持有的那个

**重启 dsh 治不好**:flock 随进程退出释放,但 dsh 启动后 resume 该会话时
会重新获取 —— 实测重启(08:39)后 09:11 的信仍然秒失败。

## 修法

普通投递路径在 `startAgent` 前加 `ctx.agents?.get?.(attemptSessionId)`,
命中就直接 `followup`,不重复 resume。与接管路径对齐。

## 判据(+2 格,钉「每个 startAgent 调用点都要查 live」)

不钉某一行,而是「**每个** `startAgent` **调用点**前面都必须有 live 检查」
—— 少一处就红。这比钉形状更能防「修一处漏另一处」:
那个错误本轮已犯过一次(先修 `modelTitle` 的字段名,漏了 `lastAssistantText`)。

★ 这份判据自身被我的错误连累了 6 轮才转红,全部是「观察面比语义窄/宽」:
  1. 正则假设函数无返回类型 + 4 空格缩进(实际 `): any[] {` + 2 空格)
  2. 把**函数定义**当调用点(`[^=]*` 匹配过 `async function startAgent(`)
  3. 固定 30 行窗口在剥离注释后行号偏移 ⇒ 接管路径被误判成「没检查」
  4. 正则只吃 `get(`,不吃 `agents?.get?.(` 的**双可选链**
  5. 正则多了 `\.`(`ctx.agents?.get?.` 里 agents 后没有点)
  6. `.test()` 带了 `/g` ⇒ `lastIndex` 在连续调用间保留,结果交替
最终改为「往上找最近的 `get(`,不限定变量名、不限窗口大小」,两个变异都转红。

dsh 442 格全绿 · tsc 零错 · 部署成功(current → 20261004-093035)· 心跳正常。
This commit is contained in:
2026-10-04 09:30:57 +08:00
parent d94717aa95
commit 3673019ec4
2 changed files with 145 additions and 1 deletions

View File

@ -1185,7 +1185,7 @@ export function apply(ctx: any, config: PluginConfig): void {
return { sessionID: adoptedID, reused: true };
}
const { handle } = await startAgent(adoptedID, onDisk, attemptOrder()[0]);
applyPermissionMode(handle.agent?.session, data.permission_mode);
applyPermissionMode(handle.agent?.session, data.permission_mode);
bindAdopted(mailSessionID, adoptedID, onDisk, data);
handle.agent.followup(userMessage(promptText));
await waitForTurnEnd(handle.agent);
@ -1322,11 +1322,39 @@ export function apply(ctx: any, config: PluginConfig): void {
let handle: any;
try {
// ★ 2026-10-04 修复(同函数 1187 行的接管路径早就这么做,这里漏了):
//
// 先查这条会话是不是**已经活着**。是就直接 followup,不要再 resume 一次。
//
// 为什么必须:dsh 的会话持久化用 **flock 独占锁**
// (dsh-session-persistence-jsonl:`tryLockExclusive` 失败即抛
// `SessionAlreadyOwnedError`)。而 `startAgent` 在磁盘上已有该 id 时走
// `ctx.agents.resume()` —— 它会**再申请一次写 lease**,撞上自己已持有的锁:
//
// 会话 mail-515c0c70… 已在磁盘上(cwd=未记录),改为 resume 续谈
// 建会话失败 llmsproxy/AUTO: session "mail-515c0c70…" is already owned
// by an active write handle
//
// 症状是**秒回失败通知**:一封都没进去,用户只看到「处理失败」。
// lsof 确认持有者就是 dsh 自己 ⇒ 不是残留锁、不是别的进程,是自己撞自己。
// 而 flock 随进程退出释放,所以**重启 dsh 也治不好** —— 下次 resume 照样撞。
//
// 1187 行的接管路径早有这段去重,注释写得很清楚(同一条会话两个 handle
// 会各自往日志里写、replay 校验不过)。同一个陷阱漏在普通路径上。
const alreadyLive = ctx.agents?.get?.(attemptSessionId);
if (alreadyLive) {
handle = alreadyLive;
console.error(
`[dsh-mail-bridge] 会话 ${attemptSessionId} 已在运行,直接 followup(不重复 resume)`,
);
} else {
const started = await startAgent(attemptSessionId, cwd, route);
handle = started.handle;
// 设定权限档位:sandbox/mode 决定文件与命令边界,
// approval/policy 决定越界时是否转邮件问人。
applyPermissionMode(handle.agent?.session, data.permission_mode);
}
} catch (e: any) {
// create/resume 本身很少失败(create 不校验模型),
// 但 cwd 不符、日志 replay 不过之类仍会抛

View File

@ -162,3 +162,119 @@ test('★ 判据自检:把辅助函数改成优先读 events,第一格必须
'它抓不到自己要抓的东西,等于没有',
);
});
/*
「resume 前必须查 live」判据(2026-10-04)。
# 症状
用户报「dsh 秒回错误」—— 每封来信立刻收到 `处理失败`,一封都没进去:
会话 mail-515c0c70… 已在磁盘上(cwd=未记录),改为 resume 续谈
建会话失败 llmsproxy/AUTO: session "mail-515c0c70…" is already owned
by an active write handle
# 根因
dsh 的会话持久化用 **flock 独占锁**
(`dsh-session-persistence-jsonl`:`tryLockExclusive` 失败即抛 `SessionAlreadyOwnedError`)。
`startAgent` 在磁盘上已有该 id 时走 `ctx.agents.resume()`,它会**再申请一次写 lease**,
撞上同一进程已持有的那个锁。lsof 确认持有者就是 dsh 自己。
⇒ 不是残留锁、不是别的进程,是**自己撞自己**。而 flock 随进程退出释放,
所以**重启 dsh 也治不好** —— 下次 resume 照样撞(实测:重启后仍秒失败)。
# 为什么判据盯「所有 startAgent 调用点」而不是某一行
同一文件里两处调 `startAgent`:
· 接管路径(约 1187 行)—— **早有** `ctx.agents.get()` 去重,
注释写明「同一条会话两个 handle 会各自往日志里写,replay 校验不过」
· 普通投递路径 —— 漏了 ⇒ 同一个陷阱只在一条路上发作
所以判据是「**每个** `startAgent` 调用点前面都必须有 live 检查」,
少一处就红。这比钉某一行的形状更能防住「修好一处、漏另一处」
(那个错误已经犯过一次:先修 `modelTitle` 的字段名,漏了 `lastAssistantText`)。
*/
test('★ 每个 startAgent 调用点都必须先查 live(否则撞自己的 flock)', () => {
const src = liveNow();
const lines = src.split('\n');
// ★ 只认**调用点**,不认函数定义:
// `startAgent` 函数体内有 `return await ctx.agents.resume(...)`,
// 而 `await startAgent(` 这个形状会连它一起匹配上(实测剥注释后行号
// 925/1090 都是函数体,真正的调用点是 1187/1352)⇒ 窗口里当然找不到
// live 检查,判据对着一段不该检查的代码报红。
// 判据:**赋值给变量**的才是调用点(`const x = await startAgent(...)` /
// `const { handle } = await startAgent(...)`),函数体内是 `return await …`。
const callIdx = [];
lines.forEach((l, i) => {
// `async function startAgent(` 里没有 `=`,`[^=]*` 会一路匹配过去 ⇒ 定义被当成调用。
// 收紧成「必须有 = 」,即 `const x = await startAgent(...)` / `const {h} = await startAgent(...)`。
if (/(?:const|let|var)\b[^=]*=\s*await startAgent\(/.test(l) && !/function\s+startAgent/.test(l)) callIdx.push(i);
});
assert.ok(callIdx.length >= 2, `应至少有两个 startAgent 调用点(实际 ${callIdx.length})`);
const missing = [];
for (const i of callIdx) {
// ★ 窗口从调用点**往上找到最近的 `agents.get(`**,不用固定行数:
// 两处 live 检查的位置差很多(接管路径那条紧贴调用点,普通路径那条在循环外),
// 固定 30 行在剥离注释后行号偏移时会算错 —— 实测接管路径被误判成「没有检查」。
// 找「最近的 get(」而不是「窗口内有没有」:只要在这次调用之前查过 live 即可。
let window = '';
for (let k = i - 1; k >= Math.max(0, i - 60); k--) {
if (/ctx\.agents(?:\?\.|\.)?get(?:\?\.)?\s*\(/.test(lines[k])) {
window = lines.slice(k, i).join('\n');
break;
}
}
// ★ 只看**代码**(liveNow 已剥注释),且**不限定变量名**:
// - 含 `alreadyLive` 时那段注释里也出现该词 ⇒ 抽掉代码判据照样绿;
// - 限定变量名又会漏掉接管路径的 `const live = ctx.agents.get(adoptedID)`
// (写法本来就不同,那是更早写的)。
// ⇒ 判据只问「有没有查 live」,不问「叫什么名字、怎么写的」。
if (!/ctx\.agents(?:\?\.|\.)?get(?:\?\.)?\s*\(/.test(window)) {
missing.push(`第 ${i + 1} 行`);
}
}
assert.deepEqual(
missing,
[],
'★ 这些 startAgent 调用点前面没有 live 检查:' + missing.join('、') +
'\\n ⇒ 会话已在磁盘上时走 resume,会**再申请一次写 lease**,' +
'撞上自己已持有的 flock ⇒ 每封来信秒回「处理失败」。' +
'\\n 接管路径早就有这个检查(注释:同一条会话两个 handle 会各自往日志里写);' +
'普通投递路径漏了。重启 dsh 治不好 —— flock 随进程退出释放,下次照样撞。',
);
});
test('判据自检:抽掉 live 检查必须能红', () => {
const src = liveNow();
// 抽掉**普通路径**那处 live 检查(接管路径的还在,自检必须针对新加的那处)
const broken = src.replace(
/const alreadyLive = ctx\.agents[^\n]*\n/,
'const alreadyLive: any = undefined;\n',
);
assert.notEqual(broken, src, '变异必须真的改到源码(否则下面的转红是假的)');
const lines = broken.split('\n');
const callIdx = [];
lines.forEach((l, i) => {
if (/(?:const|let|var)\b[^=]*=\s*await startAgent\(/.test(l) && !/function\s+startAgent/.test(l)) callIdx.push(i);
});
const missing = callIdx.filter((i) => {
let window = '';
for (let k = i - 1; k >= Math.max(0, i - 60); k--) {
if (/ctx\.agents(?:\?\.|\.)?get(?:\?\.)?\s*\(/.test(lines[k])) {
window = lines.slice(k, i).join('\n');
break;
}
}
return !window;
});
assert.ok(
missing.length > 0,
'★ 判据在「live 检查被抽掉」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西',
);
});