## 症状(上一版修复后暴露的新错)
会话 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 格。
362 lines
17 KiB
JavaScript
362 lines
17 KiB
JavaScript
/*
|
||
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 } 包装」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西',
|
||
);
|
||
});
|