DeepSeek Harness:DSH plugin resume 报 already owned 的写锁缺口
插件触发 resume 时抛出 session "dsh-xxxxxx-032b77e23bc41872" is already owned by an active write handle,但那个会话你只是在 UI 里打开看了一眼、什么都没做。 这不是误报——「打开」本身就会激活 Agent,而激活即占写锁:客户端打开 live event stream 后,history.follow() 先返回 snapshot,接着对 prepared 状态的 source 调用 promote(...),在后台解析并激活 Agent;Agent 进入 agent loop 后的第一件事就是取 write handle(#7156)。这个 handle 只要存活就一直占着锁。锁其实有两层——进程内的 tracker.claimWrite(id) 与跨进程的 session.lock 内核 lease——而它们抛的是同一个异常、同一句文案,所以要靠失败瞬间 ctx.agents.get(id) 是否为 undefined 来分辨(#7156 回复)。插件侧的正确姿势是 resume-or-deliver:先问注册表,活着就投递,没人持有才 resume,竞态输了就转投给赢家。 但要说清楚——这只保护你的插件,「让只读打开真的免费」仍是一个核心改动。
先分诊:两层锁,一个错误码,用一行代码分辨
关键结论:先确定你在撞哪一层,因为「改插件能解决」只对其中一层成立。
| 判据 | 层一:同进程 claim | 层二:跨进程 lease |
|---|---|---|
| 实现 | tracker.claimWrite(id)(storage.ts:429) | <session dir>/session.lock 的内核 lease(lease.ts:71-115) |
| 机制 | 进程内的写入者跟踪表 | POSIX 排他锁;Windows 上表现为共享冲突 |
失败瞬间 ctx.agents.get(id) | 非 undefined(同进程活 Agent) | undefined |
| 能否靠插件自身修好 | 能(resume-or-deliver) | 不能,你得先找到并结束那个进程 |
| 常见成因 | 你在 UI 里打开过该会话 | 另一个 dsh 进程仍持有该会话 |
判据的具体用法:
const live = ctx.agents.get(id) // 公开 API:AgentRegistry.get
if (live !== undefined) {
// 层一:同进程已有活 Agent 拿着 claim
} else {
// 层二:另一个 dsh 进程持有 session.lock 的排他锁
}
一个必须知道的坑:POSIX 下 lease 释放后 session.lock 文件不会被删除(lease.ts)。所以「文件存在」不能作为「有人持有」的证据——真正的持有者由该文件上的排他锁决定,不是文件本身。用文件是否存在来判断会让你得出完全相反的结论。
机制:为什么「只是打开」就占了写锁
这一节的结论:占用发生在显示快照之后的后台激活里,用户感知不到。
1. 打开会话的完整链路(5 步)
- 客户端打开 live event stream;
history.follow()先返回 snapshot;- 对被观察的 source 为
prepared的会话,返回 snapshot 后立即后台调用promote(...)(packages/api/session-controller/src/history.ts:203-206); promote()尝试agents.resume();若没有现存 Agent,就进入 Agent resume 流程(packages/api/session-controller/src/index.ts:173-184);- Agent loop 最终执行
persistence.open(id, 'write'),原子地抢占写锁(packages/core/agent-loop/src/index.ts:891-895、packages/session/session-persistence-jsonl/src/index.ts)。
于是三个反直觉的事实:
- 「打开但不执行模型」不等于「不占写锁」——Agent 激活本身就占;
- 切到其他会话也不一定释放——客户端设计会让 session 常驻,离屏的 Remote source 继续运行;
- 最新代码只是把冲突错误规范化成了
session/writer-held,并没有取消自动 promote / resume 的行为。
page() 这类只读路径确实不会激活 Agent,但普通 UI 打开走的是 live follow 路径,所以会。
2. 两层锁,同一个异常
- 进程内:
tracker.claimWrite(id)——packages/session/session-persistence-jsonl/src/storage.ts:429; - 跨进程:
<session dir>/session.lock上的内核 lease ——packages/session/session-persistence-jsonl/src/lease.ts:71-115。
两者抛的是同一个 SessionAlreadyOwnedError,文案就是你看到的那句(packages/session/session-persistence/src/errors.ts:34),API 路径再把它规范为稳定码 session/writer-held(packages/api/session-controller/src/agent.ts:218-219)。
3. 只有 write open 会 claim,而且刻意放在最前面
open() 的读分支从不触碰所有权(session-persistence-jsonl/src/index.ts:344-364);写分支第一步就 claim(:365)。agent loop 更是刻意先取锁再干别的,源码注释写得很直白:
// Taking write ownership FIRST excludes a concurrent resume of the
// same id (in this process, a live agent's handle holds the claim).
handle = await raceAbortCall(() => persistence.open(id, 'write', { signal: fused }), /* ... */)
所以那句报错的准确含义是:这个会话已经存在一个激活、并且它正握着 handle。 你的序列——「DSH 打开了会话 → 你的插件再 resume」——正好就是这段注释点名的情况。
4. 一个被低估的连锁:错误码只在一条客户端路径被处理
session/writer-held 在客户端只被一处专门处理(packages/client/ui-model-selection/src/client/index.ts:169),其余地方要么原样抛出、要么被吞掉。这就是为什么体验上总是「它就是不工作」——没有解释,也没有引导。
不对称:为什么 UI 打开没事、插件 resume 就炸
这一节的关键结论:Host API 会先复用活着的 Agent,ctx.agents.resume() 不会。
Host API 的解析路径在尝试任何动作之前就先返回已存在的活 Agent(liveAgent(sessionId),packages/api/session-controller/src/agent.ts:187-188),所以同一个会话从 UI 走完全正常。而 ctx.agents.resume() 没有这个前置检查——它直接走工厂、直接执行 write open,于是撞上已存在的 claim。
这解释了为什么「同一个操作,手动做没问题、插件做就报错」——不是权限问题,是入口不同。
修法:resume-or-deliver 与现成实现
修法一(插件侧,首选):resume-or-deliver
核心思路:不要无脑 resume。先问注册表;活着就投递;确认没人持有才 resume。
const live = ctx.agents.get(id) // 公开 API:AgentRegistry.get
if (live !== undefined) live.followup(message) // 投递给活 Agent —— 不取写锁
else await ctx.agents.resume({ resumeSessionId: id }) // 安全:此刻没人持有
这是树内第一方代码已经在用的形状,不是技巧:
packages/api/session-controller/src/commands.ts:359-361对用户 prompt 就是这么做:先守一道「我持有的 Agent 仍是注册表里那个」,再agent.steer(message)/agent.followup(message);packages/subagent/subagent/src/inbox.ts:54与packages/schedule/schedule/src/index.ts:50+runtime.ts:161,273同样如此:附着到活 Agent,动作前重新检查存活(ctx.agents.get(agent.id) === agent && ctx.agents.roots().includes(agent)),用followup投递,从不 resume。
也就是说:「真正要跑的时候才取锁」这个语义在 Agent 对象层面已经存在;缺的只是 service 上没有 resumeIfLive() 这样的语法糖。
参数形式必须写对(这一点被专门更正过)
await ctx.agents.resume({ // packages/core/agent/src/index.ts:407
resumeSessionId: id, // 唯一必填
agentOptions: ctx.agentDefaultModel.currentSelection(), // 可选;Host API 传的就是它
setup: composition.setup, // 可选
})
带 (ownerCtx, options) 两个参数的那个是 AgentFactory.resume,插件永远不会直接调它;两参数的写法编译不过。核对依据:规范调用点 packages/api/session-controller/src/agent.ts:437、packages/bundle/headless/src/index.ts:279,以及 ResumeAgentOptions 的字段表。
再补一步:竞态落败就转投,别报错
「先问、没人再 resume」之间仍然存在窗口:可能刚好有一个激活注册进来。健壮的做法是接受落败——如果 claim 被那个刚注册的激活抢走,就把消息投递给它,而不是把失败抛给用户。
修法二(现成实现):@argszero/cordis-plugin-session-trigger
不想自己写这套逻辑的话,可以直接用这个插件,它就是为这个缺口做的:
import { deliverToSession } from '@argszero/cordis-plugin-session-trigger'
const { path } = await deliverToSession({ agents: ctx.agents }, { sessionId, message })
// path: 'live' | 'live-after-race' | 'resumed'
它的行为定义得很清楚:
- 先问注册表,
live的情况绝不尝试 resume; - 只有无人持有时才 resume;
- 接受竞态落败并转投——如果 claim 被与此同时注册的激活抢走,消息交给那个 Agent;
- 确实被持有时,给出同时点名两层的诊断(本进程写 claim / 另一个 dsh 进程持有
session.lock),并分别说明该怎么办。
挂载方式(cordis.patch.yml insert,配置 mode: followup|steer、retain: true|false):它会注册 ctx.sessionTrigger,并保留它 resume 出来的每一个 Agent——因为排队的那一轮需要 handle 活着;卸载时统一 dispose,这也正是把 claim 释放掉的动作。
适配性上有一个细节值得学:这个包在运行期不 import 任何 @deepseek-ai/dsh-* 模块——它只针对有文档的 ctx.agents(get、resume)与可选的 ctx.agentDefaultModel(currentSelection)编程,识别拒绝时按错误名匹配、以消息文案兜底。因此一份构建就能同时服务 0.1.2 / 0.1.3 / 0.1.5 / 0.1.6 几条线。
它的测试也值得一提:跑在真实的 @deepseek-ai/cordis 上下文与已发布 dsh-session-persistence 导出的真实 SessionAlreadyOwnedError 上(不是长得像的替身),并且带一条对照臂——被它取代的朴素 ctx.agents.resume() 必须在同一夹具下的活会话上失败。15 个测试,另有 6 处对插件自身逻辑的变异,每一处都被确认能让套件变红。
边界与附带教训:插件不受伤 ≠「打开」免费
边界:这只是让你的插件不受伤,不是让「打开」免费
这一节是对预期最重要的一节,别跳过。
核心侧的设计是:每一次激活都在最前面取写所有权,而且今天没有任何机制能让插件促使 UI 释放它。所以:
- 插件侧的模式只保护插件自己的 resume;
- 它不能让「单纯看一看」变得免费;
- 你最初那个诉求——「打开时别占锁、发 prompt 时再占」——是一个核心改动:要么做成惰性取写的 write,要么提供真正只读的 follow 路径。
如果要把这件事上报,这就是应当明确写出的诉求:不是「修复某个插件的报错」,而是「为 UI 提供只读打开语义,或让写 claim 惰性化」。
附带教训:打包时漏声明依赖,装上去直接 ERR_MODULE_NOT_FOUND
同一个讨论里还有一段值得单列的经验,因为它是发布环节的坑而不是逻辑 bug。
@argszero/cordis-plugin-session-trigger 的 0.1.0 装不上:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@deepseek-ai/schemastery'
imported from .../node_modules/@argszero/cordis-plugin-session-trigger/lib/index.js
原因很经典:构建产物 lib/index.js 里 import 了 @deepseek-ai/schemastery(Config 背后的 schema 层),但 0.1.0 只在 devDependencies 里声明了它。在作者自己的仓库里这个 specifier 能解析——pretest 会构建、workspace 树里有这个包——所以测试套件根本看不见这个问题。唯一能暴露它的是「在一个干净项目里按名字安装已发布 tarball」。
有意思的是这一点:作者当时是按 tarball 安装验证的,而 tarball 安装恰好让依赖在树里可得;换成按名字从 registry 安装才暴露。同样的字节,不同的解析结果。
修法与加固(0.1.1,npm latest,repo 936559d):
- 把
@deepseek-ai/schemastery声明进dependencies——这也正是仓内消费者的做法(packages/core/tools、packages/llm/llm-deepseek都声明在dependencies而不是 peer); - 加一个
test/packaging.spec.mjs:扫描构建后的lib/与src/里的裸 specifier,断言每一个都出现在 manifest 里;同时反向断言——没有任何被声明却没被 import 的。这条守卫不是装饰,把dependencies块去掉后它会失败并给出精准诊断:
lib/ imports @deepseek-ai/schemastery but package.json declares only @deepseek-ai/cordis —
a clean install of the published tarball will fail with ERR_MODULE_NOT_FOUND
0.1.1 上的验证:18/18 测试(15 行为 + 3 守卫);守卫在真实的 0.1.0 缺陷上失败、在修复后通过;打包后的 tarball 能装能 import;15 个行为测试针对已安装产物运行,import 经由 node_modules 解析到 @argszero/cordis-plugin-session-trigger 而非本地 lib/。
最后一句是最有价值的结论:按名字安装(在空目录里 npm i <pkg> 再 import)现在应当作为首要检查,而不是 tarball 安装——没有任何别的东西能见证「未声明的依赖」。
排查注意事项
- 先用
ctx.agents.get(id)分层(非 undefined = 同进程活 Agent;undefined = 跨进程 lease),再谈修法。 - 别用
session.lock文件是否存在做判断。POSIX 下 lease 释放后文件不会删,存在与否都不说明问题。 - 「只是打开」也是激活。
history.follow()→promote()→ agent loop → write claim,这条链全在后台,用户无感。 - 切走会话不等于释放。离屏的 Remote source 会继续运行。
- 注意 UI / 插件的不对称:Host API 先复用活 Agent(
liveAgent(sessionId)),ctx.agents.resume()没有这个前置检查。 ctx.agents.resume()只有一个参数(resumeSessionId必填);两参数形式属于AgentFactory.resume,别混。- 别只 catch 不处理。
session/writer-held在客户端只被一处专门处理,其余地方原样抛出或被吞——这就是「它就是不工作」的来源;你自己接到时要给出带两层的诊断。 retain语义要理解:排队的那一轮需要 handle 活着,所以插件必须保留它 resume 出来的 Agent,并在卸载时 dispose——那才是释放 claim 的时机。- 插件要跨版本,就别 import
dsh-*包,按错误名 + 消息兜底匹配;否则一条版本线一个构建。 - 发 npm 包前,用「空目录按名安装再 import」做最终检查,并加一条「构建产物里每个裸 specifier 都在 manifest 里」的守卫测试。
来源
- #7156 — session-persistence抢占session.lock导致插件无法resume
@argszero/cordis-plugin-session-trigger:npm / 源码
文中源码位置(storage.ts:429、lease.ts:71-115、errors.ts:34、agent.ts:187-188 / :218-219 / :437、history.ts:203-206、session-controller/src/index.ts:173-184、agent-loop/src/index.ts:891-895、session-persistence-jsonl/src/index.ts:344-364 / :365、commands.ts:359-361、inbox.ts:54、schedule/src/index.ts:50 与 runtime.ts:161,273、agent/src/index.ts:407、ui-model-selection/src/client/index.ts:169)与两层锁的判据、插件行为与测试结果均来自该讨论贴的实测与源码核对;「两参数 resume」与「lock 文件不删」两点在讨论中被明确更正。
写会话、锁与并发相关的插件时,最容易低估的是「谁在什么时候持有什么」——尤其当占用发生在后台、用户只看到一个失败提示的时候。DSH Plugin Hub 提供插件市场、已安装插件列表、自定义安装、设置与系统日志五个界面:已安装列表标注每个插件的来源与版本并可直接定位目录,系统日志页按分类与级别留存安装、卸载、诊断的操作轨迹并支持导出全文,通知中心则集中显示历史记录与进行中任务的实时进度。排查「哪个插件在什么时候做了什么」时,先把现场对齐会省下大量猜测。

常见问题
因为「打开」本身就会激活 Agent。链路是:客户端打开 live event stream → history.follow() 先返回 snapshot → 当 observed source 是 prepared 时就调用 promote(...) → 在后台解析并激活 Agent → Agent 激活即进入 agent loop → agent loop 第一件事就是取 write handle。这个 handle 只要存活就一直占着写锁,所以你「只是看看」,锁已经被取走了。
在失败的那一刻看 ctx.agents.get(id)。**非 undefined** 说明是同进程的活 Agent 占着(走 tracker.claimWrite(id) 这一层);**undefined** 说明是另一个进程持有内核 lease(session.lock 那一层)。注意别用「session.lock 文件是否存在」来判断——POSIX 下 lease 释放后文件**不会被删除**,所以文件存在什么都不证明,真正的持有者由该文件上的排他锁标识。
因为 Host API 的解析路径会**先返回已经活着的 Agent**、再考虑要不要做别的事(liveAgent(sessionId)),所以 UI 那条路根本不会去碰写开;而 ctx.agents.resume() 没有这个前置检查,它直接走工厂并执行 write open,于是必然撞上已存在的 claim。这个不对称是插件作者最容易困惑的一点。
一个。注册表包装层是 ctx.agents.resume({ resumeSessionId: id, agentOptions?, setup? })(packages/core/agent/src/index.ts:407),resumeSessionId 是唯一必填字段。带 (ownerCtx, options) 两个参数的那个是 AgentFactory.resume,插件永远不会直接调它。这一点在讨论里被专门更正过——两参数的写法编译不过。
今天没有。按核心侧的设计,**每一次激活都会在最前面取写所有权**,并且没有任何机制能让插件让 UI 释放它。所以「打开时别占锁、发 prompt 时再占」这个诉求是**核心改动**(要么做成惰性取写的 write、要么提供真正只读的 follow 路径),不是插件能自己解决的事。插件侧能做的只是「不让自己成为这个行为的受害者」。
相关术语
- SessionAlreadyOwnedError / session/writer-held
- 会话已有写入者时抛出的错误。同进程的 `tracker.claimWrite(id)` 与跨进程的 `session.lock` lease 抛的是**同一个**异常类型、同一条文案(`packages/session/session-persistence/src/errors.ts:34`),API 路径再把它规范成稳定错误码 `session/writer-held`(`packages/api/session-controller/src/agent.ts:218-219`)。因为两层共用一个码,所以必须另找判据区分层次。— https://github.com/deepseek-ai/deepseek-harness/discussions/7156
- promote(快照之后的后台激活)
- `history.follow()` 在返回 snapshot 之后,对被观察的 source 为 `prepared` 的会话调用的动作(`packages/api/session-controller/src/history.ts:203-206`),它在后台解析/激活 Agent。这就是「只打开也占锁」的直接来源:显示的瞬间,写 claim 已经在路上了。— https://github.com/deepseek-ai/deepseek-harness/discussions/7156
- write-first(先取写所有权)
- agent loop 的刻意顺序:在读或修复任何东西**之前**先拿 write handle,用于排除同一 id 的并发 resume(`packages/core/agent-loop/src/index.ts:891-895` 的注释直接点明这一点)。它与 `open()` 的读分支形成对比——读分支从不触碰所有权(`session-persistence-jsonl/src/index.ts:344-364`),写分支第一步就 claim(`:365`)。— https://github.com/deepseek-ai/deepseek-harness/discussions/7156
- adopt a losing race(竞态落败即转投)
- 插件侧的一种健壮化处理:如果自己在「先问注册表、没人再 resume」之后仍然被一个**与此同时刚注册好的激活**抢走了 claim,就不要再报错,而是把消息投递给**那个** Agent。它把「必现失败」变成「最坏情况下也只是多走一步」,是 resume-or-deliver 模式的关键一环。— https://github.com/deepseek-ai/deepseek-harness/discussions/7156
来源
- #7156 — session-persistence抢占session.lock导致插件无法resume· deepseek-ai(GitHub Discussions)