diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index afb325a..fafe5b2 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -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 不过之类仍会抛 diff --git a/plugins/dsh-mail-bridge/test/session-events-source.test.mjs b/plugins/dsh-mail-bridge/test/session-events-source.test.mjs index fcdfdb9..b97a5df 100644 --- a/plugins/dsh-mail-bridge/test/session-events-source.test.mjs +++ b/plugins/dsh-mail-bridge/test/session-events-source.test.mjs @@ -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 检查被抽掉」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西', + ); +});