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:
@ -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 不过之类仍会抛
|
||||
|
||||
@ -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 检查被抽掉」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user