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)· 心跳正常。
This commit is contained in:
@ -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 不过之类仍会抛
|
||||
|
||||
Reference in New Issue
Block a user