Commit Graph

3 Commits

Author SHA1 Message Date
caffe2f9fe fix(dsh): agents.get() 返回 agent 本体,当 handle 用缺一层 .agent
## 症状(上一版修复后暴露的新错)

    会话 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 格。
2026-10-04 12:03:58 +08:00
3673019ec4 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)· 心跳正常。
2026-10-04 12:03:58 +08:00
bf4fa6e587 fix(dsh)★★: 事件数据源字段名错 —— session.events 不存在,真实是 session.log
## 用户报「dsh 插件还是没有正确回复邮件」(第二版)

519bdf7 改了 `lastAssistantText` 的**解析逻辑**(从后往前找带 text 的那条),
生产仍复现。**解析逻辑本来就是对的**,错的是**数据源字段名**。

## 根因(运行时实测,不是推断)

在 idle 回调里打印 agent/session 的实际键表,得到:

    session 键:[log, surfaceManager, header, inheritedEventCount,
                 firstLiveSeq, firstLifecycleSeq, eventsSnapshot,
                 headerFold, headerFoldSeq, contextFold, contextFoldSeq,
                 toolHistoryProjection, toolHistorySeq, derived, …]

**没有 `events`。** ⇒ `agent.session?.events ?? []` **永远**兜成空数组
⇒ 每一次都判「空回复」⇒ 给人类发件人发「处理失败」通知。

真正的事件序列是 **`log`**:实测 `Array(26)`、末条 `type=turn/end`、
键 `[type,seq,time,data]` —— 与磁盘 `session.v4.jsonl.zstd` 逐字一致
(磁盘 27 条 / 内存 26 条,差 1 条是 idle 瞬间最后一条尚未写入)。
★ `eventsSnapshot` 名字最像,**实测是 null** —— 照名字写代码会第二次踩坑。

## 顺带修掉第二处同源缺陷

`modelTitle(a?.session?.events ?? [])`(会话标题同步)**也是同一个错字段**,
所以会话标题一直同步不上(静默失败)。
两处曾各写各的、修一处漏一处 ⇒ 收敛为单一函数 `sessionEvents()`,
判据禁止任何地方绕过它。

## 被观测否定的两个推断(都记下来)

1. **「idle 时最后一条事件还没落入内存」** ⇒ 我为此加了 3×250ms 重试
   (现已保留,成本极低且无害)。观测 `events=0`(重试 750ms 后**仍为 0**)
   直接否定:不是没到,是**压根没有那个字段**。
2. **「根因是 resume 路径」** ⇒ 新会话与 resume 两次都「正常」,因为 dsh
   都主动 `send_mail` 而在 `shouldSkipAutoRelay` 早退,压根没走到取文本那段。

## 判据(新增 4 格,含接线层)

纯函数测试抓不到这一类缺陷(`lastAssistantText` 的单元测试全绿)。
新增 `test/session-events-source.test.mjs` 钉住**调用点**:

* 辅助函数必须存在且 **log 排在 events 之前**(判**顺序**不是判存在 ——
  `events ?? log` 也含 `.session?.log`,用 `includes` 会免疫)
* 任何读会话事件的地方都必须走 `sessionEvents()`,豁免**按位置**判定
  (用 `helperBody.includes(frag)` 豁免会放过代码里**任何位置**的同名字段)
* 回退链保留 `events`(万一是更老的运行时,不比原来更差)
* 自检:合成坏源码必须判红

★ 这份判据自身也踩了两次「对变异免疫」:
`const code = readFileSync()` 模块级缓存、以及上面那个 includes 豁免。
两处都改成**每次现读 / 按位置判定**后才真正有区分力。

**变异验证**:辅助函数改回 events 优先 → 红;把调用点改回直接读字段 → 红。

## 验证状态

dsh 440 格全绿 · tsc 零错 · 部署成功(current → 20261004-080331)· 心跳正常。

⚠ **最终行为验证未完成**:需要一次真人发信(`from_human=true`)才会走到
自动转发那段。我的所有身份都是 Agent,会被「不自动转发」早退。
判空时的 `[diag]` 行已长期保留 —— 下次复现可直接看出是「没有文本」还是「没有事件」。
2026-10-04 08:04:10 +08:00