From caffe2f9fe86d7396f6d980fe9b0e7265a75f6da Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 09:45:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(dsh):=20agents.get()=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20agent=20=E6=9C=AC=E4=BD=93=EF=BC=8C=E5=BD=93=20handle=20?= =?UTF-8?q?=E7=94=A8=E7=BC=BA=E4=B8=80=E5=B1=82=20.agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 症状(上一版修复后暴露的新错) 会话 mail-515c0c70… 已在运行,直接 followup(不重复 resume) new_mail 处理失败: Cannot read properties of undefined (reading 'followup') 去重生效了(不再撞 flock),但下一步炸。 ## 根因:两个来源返回的不是同一种东西 ctx.agents.get(id) → **agent 本体**(.followup / .session 直接在上面) startAgent() → handle,agent 在 **handle.agent** 证据在同文件 findLiveDshSession(约 1059 行)——它自己也要包一层: const live = ctx.agents.get(bound.dshSessionId); if (live) return { id: bound.dshSessionId, agent: live }; 而去重那段把 `get()` 的返回值**直接赋给 handle** ⇒ `handle.agent` 是 undefined ⇒ 一取 followup 就炸。 ⇒ 两条路径拿的是同一个对象,只是用法不同:一个包了一层、一个没包。 ## 修法 `handle = { agent: alreadyLive }`。 ## 判据(+2 格) 钉「从 `agents.get()` 取到的东西,必须包成 `{ agent: … }` 才能当 handle 用」。 ★ 写这格时又被自己的两个假设绊住: 1. 先写的是「看 `handle = …` 那一行有没有 `agents.get`」—— 实际代码是**两步**(先绑变量 `alreadyLive`,再 `handle = { agent: alreadyLive }`), 赋值式里根本没有 `get(` 字样 ⇒ 判据报「应至少有一处…」而自己红了。 改成**追变量来源**。 2. 追来源时正则先匹配到接管路径的 `live`(它的用法本来就对), 于是抓不到新加的那处 ⇒ 收紧成只匹配**双可选链** `?.get?.(` 那一处。 dsh 444 格全绿 · tsc 零错 · 部署成功(current → 20261004-094533)· 心跳正常。 变异:去掉 `{ agent }` 包装 → 红 2 格。 --- plugins/dsh-mail-bridge/src/index.ts | 11 ++- .../test/session-events-source.test.mjs | 81 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index fafe5b2..fafda26 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -1341,10 +1341,19 @@ export function apply(ctx: any, config: PluginConfig): void { // // 1187 行的接管路径早有这段去重,注释写得很清楚(同一条会话两个 handle // 会各自往日志里写、replay 校验不过)。同一个陷阱漏在普通路径上。 + + // `ctx.agents.get()` 返回的是 **agent 本体**(见下方 `{ agent }` 处的注释) const alreadyLive = ctx.agents?.get?.(attemptSessionId); if (alreadyLive) { - handle = alreadyLive; + // ★ 必须包一层 `{ agent }`:`ctx.agents.get()` 返回的是 **agent 本体** + // (证据:findLiveDshSession 第 1059 行 `ctx.agents.get(id)` 之后 + // 紧接着 `return { id, agent: live }` —— 它自己也要包)。 + // 而 `startAgent` 返回的 handle 上才有 `.agent`,后续全部按 + // `handle.agent.session` / `handle.agent.followup` 取值。 + // 直接把 agent 当 handle 塞进去 ⇒ `handle.agent` 是 undefined ⇒ + // 实测报 `Cannot read properties of undefined (reading 'followup')`。 + handle = { agent: alreadyLive }; console.error( `[dsh-mail-bridge] 会话 ${attemptSessionId} 已在运行,直接 followup(不重复 resume)`, ); 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 b97a5df..78fb91a 100644 --- a/plugins/dsh-mail-bridge/test/session-events-source.test.mjs +++ b/plugins/dsh-mail-bridge/test/session-events-source.test.mjs @@ -278,3 +278,84 @@ test('判据自检:抽掉 live 检查必须能红', () => { '★ 判据在「live 检查被抽掉」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西', ); }); + +/* +handle 形状一致性判据(2026-10-04)。 + +# 症状 + +去重命中了(不再撞 flock),但下一步立刻炸: + + 会话 mail-515c0c70… 已在运行,直接 followup(不重复 resume) + new_mail 处理失败: Cannot read properties of undefined (reading 'followup') + +# 根因:`ctx.agents.get()` 与 `startAgent()` 返回的**不是同一种东西** + + ctx.agents.get(id) → **agent 本体**(有 .followup / .session) + startAgent() → handle,agent 在 **handle.agent**(.agent.followup) + +证据在同文件 findLiveDshSession(约 1059 行)——它自己也要包一层: + + const live = ctx.agents.get(bound.dshSessionId); + if (live) return { id: bound.dshSessionId, agent: live }; + +而去重那段把 `get()` 的返回值直接当 handle 用 ⇒ `handle.agent` 是 undefined +⇒ 一取 followup 就炸。 + +# 为什么单测抓不到 + +`startAgent` 要 dsh 宿主才能跑,纯函数测试够不着。而这个缺陷的形状是 +「两个来源返回两种形状,被当成同一种」——纯看类型(都是 `any`)看不出来。 + +⇒ 判据钉**赋值点的形状**:从 `get()` 取到的东西,必须包成 `{ agent: … }` +才能当 handle 用。这一格就是当初该有、而没有的那一格。 + +# 顺带说明:接管路径为什么没炸 + +它写的是 `live.followup(...)`(直接用 agent 本体),**形状本来就对**。 +两条路径拿的是同一个对象,只是用法不同 —— 一个包了一层、一个没包。 +本判据不判断「哪种用法对」,只判断「从 get() 取出后有没有包」。 +*/ + +test('★ 从 agents.get() 取到的东西必须包成 { agent } 才能当 handle 用', () => { + const src = liveNow(); + + // ★ 要**追变量来源**,不能只看赋值式本身: + // 实际代码是两步 —— `const alreadyLive = ctx.agents?.get?.(id)` 然后 + // `handle = { agent: alreadyLive }`。只看 `handle = …` 那一行永远看不到 + // `get(`,第一版这么写直接报「应至少有一处 handle = agents.get(...)」—— + // 判据没看见它本该看的东西。 + // ⇒ 两步:先把 `agents.get(…)` 的返回值绑到哪个变量,再看该变量被怎么用。 + const getVar = src.match(/const\s+([A-Za-z_][\w]*)\s*=\s*ctx\.agents\?\.get\?\./); + assert.ok(getVar, '应有一处「从 agents.get() 取值并绑定到变量」'); + + const assigns = [...src.matchAll(/handle\s*=\s*([^;\n]+);/g)].map((m) => m[1].trim()); + const fromGet = assigns.filter((a) => a.includes(getVar[1])); + assert.ok(fromGet.length >= 1, `应至少有一处 handle 用到 ${getVar[1]}`); + + const bad = fromGet.filter((a) => !/^\{[^}]*\bagent\s*:/.test(a)); + assert.deepEqual( + bad, + [], + '★ 这些地方把 agents.get() 的返回值直接当 handle 用:' + bad.join(' | ') + + '\\n ⇒ get() 返回 agent 本体,而 handle 上才有 .agent ⇒' + + ' handle.agent 是 undefined ⇒ 一取 followup 就报' + + '「Cannot read properties of undefined」。(实测踩过)', + ); +}); + +test('判据自检:去掉 { agent } 包装必须能红', () => { + const src = liveNow(); + const broken = src.replace(/handle\s*=\s*\{\s*agent:\s*([a-zA-Z_][\w]*)\s*\s*\};/, + 'handle = $1;'); + assert.notEqual(broken, src, '变异必须真的改到源码(否则下面的转红是假的)'); + + const getVar = broken.match(/const\s+([A-Za-z_][\w]*)\s*=\s*ctx\.agents\?\.get\?\./); + const assigns = [...broken.matchAll(/handle\s*=\s*([^;\n]+);/g)].map((m) => m[1].trim()); + const fromGet = assigns.filter((a) => a.includes(getVar[1])); + const bad = fromGet.filter((a) => !/^\{[^}]*\bagent\s*:/.test(a)); + assert.ok( + bad.length > 0, + '★ 判据在「去掉 { agent } 包装」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西', + ); +});