/* DSH 会话事件数据源的**接线**判据(2026-10-04)。 # 为什么需要这一格(纯函数测试抓不到) `lastAssistantText` 是纯函数,它的单元测试全绿 —— 而生产上「模型写了完整回信, 插件却发『处理失败:空回复』」。根因不在纯函数,在**调用点读了不存在的字段**: agent.session?.events // dsh 0.2.0-rc.2 的 session 没有这个键 // ⇒ ?? [] 永远兜成空数组 // ⇒ 每一次都判空 实测键表(由插件自己在 idle 时打印,见 git log 里的 24f7ed9 后续提交): [log, surfaceManager, header, inheritedEventCount, firstLiveSeq, firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq, contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq, …] 没有 `events`。事件序列是 **`log`**(Array(26),末条 turn/end,键 [type,seq,time,data],与磁盘 session.v4.jsonl.zstd 逐字一致)。 `eventsSnapshot` 名字最像,**实测是 null** —— 照名字写代码会第二次踩坑。 # 这一格钉住什么 **源码里任何「读 session 的事件」的地方都必须经过 sessionEvents()**。 为什么用源码形状判据而不用行为判据: · 行为判据(跑一轮真实会话)代价太高,且要真人发信才触发得到; · 这一类缺陷的本质是「读错字段名」,它**一定**在源码里留下痕迹。 形状判据是这类缺陷的直接证据,不是「我打算写的写法」。 # 历史:同一个错犯过两次 `modelTitle(a?.session?.events ?? [])`(会话标题同步)与 `lastAssistantText(agent.session?.events)`(自动转发)**各写各的**, 修了一处另一处还错着 —— 所以本判据禁的不只是某个字段,而是「绕过辅助函数」。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = join(HERE, '..', 'src', 'index.ts'); /** * 每次调用都现读文件。 * * ★ 踩过的坑:初版写的是模块级 `const code = readFileSync(...)`,于是 * 「改坏源码 → 跑判据 → 应红」这类变异**永远红不了** —— 模块已加载, * `code` 还是旧值。两次变异都 0 红,而我一度以为判据有效。 * 判据自己读文件,就必须**在判据里现读**,不能缓存。 */ function readCode() { return readFileSync(SRC, 'utf8'); } /** 去掉块注释与行注释,避免注释里的字段名被当成真实调用。 */ function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); } /** * 现读并剥注释。 * * ★ 同样的坑踩了两次:初版是 `const live = stripComments(code)`, * 而 `code` 又是模块级 readFileSync ⇒ 变异改文件后判据仍读旧值。 * **判据读外部文件就必须每次现读**,任何一层缓存都会让它对变异免疫。 */ const liveNow = () => stripComments(readCode()); /** * 提取辅助函数体。 * * 初版正则假设「无返回类型 + 4 空格缩进」,而实际是 * `function sessionEvents(agentLike: any): any[] {` + 2 空格缩进 * ⇒ 三格全红,而代码本来是对的。**判据自己的匹配范围比语义窄** * 也是本轮反复出现的那一族错误,所以这里以真实签名为准并写死注释。 */ const HELPER_RE = /function sessionEvents\(agentLike: any\): any\[\] \{[\s\S]*?\n \}/; function helperBodyOf(src) { return src.match(HELPER_RE)?.[0] ?? ''; } const helperBodyText = () => helperBodyOf(liveNow()); test('★ 事件数据源:辅助函数读 session.log(不是 session.events)', () => { // 辅助函数本体必须存在,且 log 优先 // ★ 必须判**顺序**,不能只判「log 出现过」。 // `log ?? events ?? []` 与 `events ?? log ?? []` 两边都含 `.session?.log ??`, // 所以 `includes` 两种写法都为 true ⇒ 判据对这次变异免疫(实测两轮 0 红)。 const hb = helperBodyText(); const logFirst = hb.indexOf('.session?.log ??'); const eventsFirst = hb.indexOf('.session?.events ??'); assert.ok(logFirst >= 0, '★ 必须读 session.log —— dsh 0.2 的 session 没有 events 键'); assert.ok( eventsFirst < 0 || logFirst < eventsFirst, '★ session.log 必须排在 session.events 之前(log 优先)—— ' + `实测 log@${logFirst} events@${eventsFirst}。` + '读成 events 优先就退回到生产事故的形状:`events` 不存在 ⇒ 恒取空。', ); }); test('★ 任何读会话事件的地方都必须走 sessionEvents(),不得直接读字段', () => { // 逐个匹配点判断「它是否位于辅助函数体内」—— // ★ 不能用 `helperBody.includes(frag)` 豁免: // 辅助函数体内**本来就含** `.session?.log` 与 `.session?.events` 两处回退, // 用 includes 豁免会把代码里**任何位置**的同名字段都一并放过 // (实测:把 `modelTitle(a?.session?.events)` 改回去,offenders 仍是 [] // ⇒ 这一格对它完全免疫)。 const src = liveNow(); const hb = helperBodyText(); const hbStart = src.indexOf(hb); const hbEnd = hbStart + hb.length; const offenders = []; for (const m of src.matchAll(/[^\w]session\??\.(?:events|log)\b/g)) { const inside = m.index >= hbStart && m.index < hbEnd; if (!inside) offenders.push(m[0].trim()); } assert.deepEqual( offenders, [], '★ 这些地方直接读了 session 的事件字段,绕过 sessionEvents():' + offenders.join(' | ') + '\n ⇒ dsh 0.2.0-rc.2 的 session 没有 events 键(实测键表见文件头注释),' + '直接读得到 undefined ⇒ ?? [] 兜成空 ⇒ 每次都判「空回复」。' + '\n 另一处(会话标题同步)曾因此长期静默失败 —— 两处各写各的、修一处漏一处。', ); }); test('★ 回退链保留 events(万一是更老的运行时字段名不同,不比原来更差)', () => { assert.match( helperBodyText(), /\.session\?\.events\s*\?\?/, '回退链应保留 session.events —— 只修当前运行时、不给老版本留路,会让「修 bug」变成「换一个版本就坏」', ); }); test('★ 判据自检:把辅助函数改成优先读 events,第一格必须能红', () => { // 复现生产事故的精确形状:辅助函数优先读 events(而不是 log) const broken = readCode().replace('.session?.log ??', '.session?.events ??'); assert.notEqual(broken, readCode(), '变异必须真的改到源码(否则下面的转红是假的)'); // ★ 用第一格**同一条判据**去判坏源码,它必须判红。 // 初版这格数的是「helperBody 之外的直接访问」,而变异把两处都留在 // 辅助函数体内 ⇒ offenders 为空 ⇒ 自检自己红了。形状错了: // 要验的是「第一格会不会转红」,不是「另一个数法会不会发现东西」。 const brokenHelper = helperBodyOf(stripComments(broken)); const bLog = brokenHelper.indexOf('.session?.log ??'); const bEvents = brokenHelper.indexOf('.session?.events ??'); assert.ok(bEvents >= 0, '变异方向错:坏源码里应有 events 优先'); const prefersLog = bLog >= 0 && (bEvents < 0 || bLog < bEvents); assert.equal( prefersLog, false, '★ 第一格判据在「辅助函数优先读 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 检查被抽掉」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西', ); }); /* 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 } 包装」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西', ); });