DSH plugin 报 already registered?resume 与 preset 冲突排查
如果你在同一个宿主进程里开第二个基于 cordis 的预设会话,却报 Host Cordis inspect provider "Service" is already registered,那不是会话坏了,而是进程级单例注册表被重复注册撞上了。 CordisInspectRegistryService 内部是一张进程全局的 provider Map,对重复 id 直接抛错;而 @deepseek-ai/dsh-tool-cordis 每次预设挂载都会无条件注册 Service / Event / Builtin / Tool 四个第一方 provider,加上预设的 standing mount 一旦建立就永不卸载——于是第一个挂载者永久占住这四个 id,其余同源预设的 resume 与新建会话此后一直失败,只有重启进程才能恢复。
DSH plugin 的两种触发场景:resume 失败与新建会话「闪回」
同一个注册表,两种截然不同的用户可见形态——一种是明确报错,一种是界面上什么都不说。 分别看:
- 场景一:
resume直接失败(原始报告):恢复一个 preset 带 Cordis 工具集的会话时报:
resume failed for session "session-…": Error: agent-presets: preset "cordis" failed to mount:
failed to apply loader entry tool-cordis (@deepseek-ai/dsh-tool-cordis):
Host Cordis inspect provider "Service" is already registered
一旦发生,这个会话再也打不开——每一次 prompt 尝试都以同样方式失败。会话存储的数据是完好的(只是挂载期失败,不是数据损坏)。环境为 DSH plugin 0.1.0-rc.6、web profile、Windows,复现条件是一个宿主进程里跑两个会话:一个用内置 cordis 预设,一个用本地自制、同样挂了 tool-cordis 行的预设(#1415)。
2. 场景一的两种具体形态:① 内置 cordis 预设的会话被异常拆除(某一轮被命令错误打断)后,它的四个 provider id 就留在进程全局注册表里,于是这个会话此后的每一次 resume 都在跟自己的残留撞;② 主机重启后,在一个本地自制预设(dsh-tool-cordis 的补丁副本)已经 live 的情况下 resume 同一个会话,同样因为原版包在 Service 上抛错而失败(#1415)。
3. 场景二:编辑预设后新建会话失败:另一台 Windows 机器上,用内置 cordis-zcode 预设,编辑过预设组合后想在同一个运行中的 dsh web 进程里新建会话,创建在预设挂载阶段以完全相同的错误失败。关键证据链:sessions.create()(裸调用、不带预设)成功了,会话日志行已创建;真正抛错的是挂载步骤(standingKeyFor → compose → apply tool-cordis)在第二次注册 Service 时。而那个「陈旧会话」是当晚动插件之前就创建的 319 字节空日志——证明只要第一条 cordis 会话在该进程挂载过,冲突就已经存在,「编辑预设」只是巧合的误导(#1415)。
4. 场景三:热重载同一个预设,界面闪回:改 ~/.dsh/profiles/web/cordis.patch.yml(或直接改 agent preset 文件),HMR 生效后点击「新增会话」→ 选择工作区 → 界面闪回「未选择工作区」,会话无法创建,界面上一个字都没有。根因是同一个注册表:ensureStanding() 在预设文件 stamp 变化时只删除 standing map 指针、不 dispose 旧代际,随后递归重挂新代际,旧代际注册的四个 Host inspect provider 永不 unwind;后端返回的正是那句一字不差的 already registered,而 Web 前端把完整的 agent-preset-invalid 错误扔了(预设名、loader entry、包名、冲突对象、文件路径全都有)。那位报告者为此做了一次逆向工程才找到根因——同一个失败,带报错是十分钟,不带是一整天(#902)。
5. 影响面与操作规则:一旦某进程承载了一条 cordis 会话,该进程内所有后续 cordis 会话的创建/resume 都会失败;唯一恢复方式是(a)重启宿主进程,或(b)用非 cordis 预设创建第二个会话。会话不会被损坏——只是挂载期失败。对重度使用者尤其致命:因为是「组合编排」类预设,工作区里会积累大量历史 cordis 会话,重启后哪个先挂载哪个赢,其余全部 resume 失败直到下一次重启(#1415)。
DeepSeek Harness 机制:进程单例 registry 与 standing mount 从不卸载
一句话概括:往「进程生命周期」的容器里写「会话生命周期」的注册,就必然会先到先得、且永久占有。 逐层拆解:
- 注册表是进程级单例:
CordisInspectRegistryService由DynamicCordisRunnerService在宿主面创建(new CordisInspectRegistryService(ctx),服务键cordisInspect,位于@deepseek-ai/dsh-cordis-host-runner)。它的register()在 provider id 已存在于内部Map时抛Host Cordis inspect provider "<id>" is already registered(#1415)。 - 每个预设挂载都无条件注册四个 id:
@deepseek-ai/dsh-tool-cordis在apply()里无条件注册第一方 Host inspect provider:
for (const provider of hostInspectProviders(ctx))
ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`);
因为注册表是进程单例,这个原版包在一个进程里只能被挂载一次。两个预设都带 tool-cordis 行(例如内置 cordis 预设和任何复制它的自制预设)就会撞:后挂载者抛错 → 整个预设挂载失败 → 会话 resume 失败。失败是顺序相关的:先挂载者胜出,并持有这些 id 直到自己的 fiber 被 dispose(#1415)。
3. standing mount 是进程生命周期的:预设的常驻挂载一旦被创建(任何会话创建、选中,甚至通过 standingKeyFor 做一次冷 transcript 读取都会触发),在进程存活期间永不 dispose——ensureStanding 之后 recompose 只重绑、不卸载前一个预设。所以第一个被挂载的 cordis 预设会永久持有全部四个 provider id(#4675)。
4. 泄漏只发生在异常拆除:独立验证(用真实包 @deepseek-ai/cordis + @deepseek-ai/dsh-cordis-host-runner 0.1.0-rc.6,写一个形状与 dsh-tool-cordis 的 apply 完全一致的插件)跑了 7 项检查全部通过:首次挂载注册四个 provider ✅;同进程第二次挂载抛出原文错误 ✅;干净 teardown(fiber.dispose())会运行收集到的 disposer,四个 id 全部释放 ✅;teardown 后重挂成功(无需重启即可恢复)✅;不 dispose 则 id 残留 ✅;残留状态下另一处挂载抛同样错误 ✅;清理可释放全部 ✅。接线方式也被核实:ctx.effect(fn) 在挂载时立即执行 fn,并把其返回值收集为拆除 disposer;register() 返回一个幂等 disposer,只删除属于自己的那条注册(#1415)。
5. 恢复路径已被实测:关闭那个自制预设上的 live 会话(正常拆除)释放了四个 provider id(effect disposer 在干净拆除时运行),卡住的 cordis 预设会话随后无需重启进程就成功 resume 了——这同时反证了泄漏只发生在异常拆除(#1415)。
6. 进程内没有绕过办法:有人试过从动态 Cordis 插件热补丁单例的 register()——动态插件沙箱阻止对宿主服务的赋值(沙箱 ctx 的 set trap 抛 sandbox ctx is read-only; cannot assign "<prop>",并把 guard 失败上报给所属 agent),所以不存在进程内的绕过。重启,或按正确顺序关闭再打开(先挂 cordis 预设会话,再开其他 tool-cordis 会话),是在上游修复前的唯一恢复方式(#1415)。
7. 另一条通往同一错误的路径是「代际泄漏」:ensureStanding 的源码里留着一句未实现的 TODO——「等最后一个挂在旧代际上的 agent 消失后再回收被取代的代际。该子树并非惰性——dsh-skill-filesystem 在监视它的根,而设置页的作者流程会把『组合变了』变成每次保存都触发的事件」。指针被丢弃、新代际被挂载,被取代的 fiber 永不 dispose,它那些 ctx.effect 注册(包括四个 Host inspect provider)自然永不 unwind。所以「两个预设共存」与「热重载同一预设」其实是同一个尚未做出的决定:预设的挂载什么时候结束?如果答案是「永不」(现状),那么任何写进进程级注册表的东西都必然是先到先得、永久占有——这不是 tool-cordis 的疏忽(#4675)。
8. 为什么「等价注册可以共享」是成立的:Service、Event、Builtin 完全不接触挂载方的 ctx,它们只是 queryServiceApi / queryEventApi / HOST_BUILTIN_INSPECTION 之上的纯函数(都是生成好的常量);只有 Tool 闭包了 ctx,形式为 ctx.tools.schemas(context.agent),而 schemas(scope) 是在一张共享注册表上过滤 this.view(scope).visible,键是查询上下文里的请求方 agent,不是挂载它的 fiber。因此没有任何 provider 的回答取决于哪个预设赢得了注册竞争——这才是共享「正确」而不只是「方便」的前提(#4675)。
DSH plugin 已修版本、正确修法与共存规避
修法按代价递增分三档:先让注册幂等且带持有者语义、再让错误可见、最后把注册表按会话/预设作用域化。 具体如下:
- 已修的部分:原始报告者在 2026-08-21 确认「可以直接打开冲突的 agent 会话了,不再需要绕行」,看起来官方修了这个具体案例。但更广的共存场景仍在:
0.1.1-rc.2与0.1.2-alpha.1上都能复现——触发方式不变(用agentPresets.copy从内置cordis预设复制一份,tool-cordis行被原样带过),后挂载者失败。值得注意的范围数据:某台机器上 8 个预设里只有那两个含tool-cordis行的受影响,其余六个(standard、ptc、minimal、careful、report、research)能无争用地共存——在一个已有其他预设挂载的进程里对每个调standingKeyFor即可验证(#1415)(#4675)。 - 一行式复现:不需要第二个会话、不需要 UI。在一个已经挂载了某个
tool-cordis预设的进程里:
await ctx.agentPresets.standingKeyFor('cordis')
// → throws: Host Cordis inspect provider "Service" is already registered
对任何不含该行的预设调用同一句则正常返回。这把故障精确定位到第二个预设的 standing mount(--dump-config 只组合 profile 层,退出码为 0)。精确代码位置:dsh-tool-cordis/lib/index.js:8523(那段注册循环)、dsh-cordis-host-runner/lib/index.js:721(注释 "Register the process-global Host registry.")、:732(if (this.providers.has(manifest.id)) throw new Error(…))(#4675)。
3. 最小可用补丁:注册循环里吞掉重复:四个第一方 provider 是静态目录、其注册可以互换,所以第二次注册时复用即可;只吸收「重复注册」这一种错误,其他错误照旧上抛,第一个注册者的行为不变:
for (const provider of hostInspectProviders(ctx))
ctx.effect(() => {
try {
return ctx.cordisInspect.register(provider);
} catch (error) {
// Another preset already registered this process-global provider:
// it describes process state, so reuse it and do not remove it.
if (!String(error?.message ?? "").includes("already registered")) throw error;
return () => {};
}
}, `tool-cordis: inspect ${provider.manifest.id}`);
代价是 provider 现在活到进程退出——在这里可以接受,因为「某个预设卸载了,所以那些 Service 不存在了」并不是一个有意义的中间状态。也有更彻底的做法:直接改注册表让重复 id 幂等;要改的话是 @deepseek-ai/dsh-cordis-host-runner/lib/index.js(bundle 入口里内联了该类)以及 lib/types/inspect-registry.js 那份独立副本(深导入可能走它),两处都要改。Windows 安装注意:~/.dsh/profiles/node_modules/@deepseek-ai/* 是指向全局 npm 安装目录的 junction,要改全局那份,不要编辑 junction 本身。另外补丁落在 node_modules 里,npm update 会被覆盖,升级后需重打(#1415)(#4675)。
4. 正确形态:一条条目 + 持有者集合,而不是「替换」也不是「引用计数」:替换会在 disposal ordering 上翻车(见上文 FAQ);而数字引用计数同样不安全——ctx.effect 的 disposer 按契约是幂等的,计数被减两次就会把仍然活着的持有者误删。正确做法是每个 id 保留一条条目,同时记录一组匿名的持有者 token,只有最后一个持有者 dispose 时才删除条目:
const existing = this.providers.get(manifest.id)
if (existing !== undefined && canonicalJson(existing.manifest) !== canonicalJson(manifest)) {
throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
}
if (existing === undefined) this.providers.set(manifest.id, { ...registration, manifest })
const holders = this.holders.get(manifest.id) ?? new Set<object>()
const token = {}
holders.add(token)
this.holders.set(manifest.id, holders)
return () => {
const live = this.holders.get(manifest.id)
if (live === undefined || !live.delete(token)) return
if (live.size === 0) { this.holders.delete(manifest.id); this.providers.delete(manifest.id) }
}
两个细节是承重的:用持有者身份而非引用计数(删除一个不存在的集合成员本来就是 no-op,身份判等顺带把幂等性免费拿到了);被占用的 id 上出现「不同清单」仍然抛错(这正是那句错误信息当初要表达的真冲突,cordis_stop 会追加的 startHostHalf 配方才能保持有意义),比较用与键序无关的序列化,因为清单里的键序没有语义。社区分支 nokkies/dsh-upstream-patches @ fix/cordis-inspect-provider-sharing 就是按这个形状实现的,基于 cd5ef8148(0.1.2-alpha.1),可直接 git am -3 应用(#4675)。
5. 必须单独提的一件事:把被吞掉的错误显示出来:后端当时已经产生了完整的 agent-preset-invalid(预设名、loader entry、包名、冲突对象、文件路径一应俱全),而 Web 客户端把它扔了,只留下「新建会话闪回未选择工作区」。这个丢弃是通用的,所以无论下一个挂载失败的原因是什么,它都会以同样「界面闪一下」的形式呈现。让错误可见比修任何一个具体原因都更值——它决定了下一个未知故障是十分钟还是一整天(#4675)。
6. 更上游的选择:把注册表按会话/预设作用域化:根治方向是让 cordisInspect 注册表(至少是其中的 host-inspect provider)按预设作用域实例化,而不是进程全局。第三方预设插件 KannaKuron/dsh-ptc-cordis-preset 独立记录过同一问题并给出同款结论——「宿主面 runner 的 inspect 注册表遇重复 provider id 即抛错,是一个进程只能开一个 cordis 模式会话的唯一根源」「关闭/归档会话并不卸载 standing 挂载,所以『现在没有创造模式会话』不代表竞争消失」「根治仍建议上游把 runner 按会话多实例化」;该插件目前用一个 monkey-patch shim 把重复注册改成替换,这也从侧面证明痛点是真实且普遍的(#4675)。
7. 顺带说清「修复后什么会消失、什么不会」:共享条目会让热重载那条路径的报错消失,但并没有修好代际泄漏——新代际会加入被泄漏代际的条目而不是与它冲突,挂载因此成功,而那个被取代的子树仍然握着它的 watcher。这是一个值得做但应当被明确记录为取舍的决定:冲突本身是个糟糕的泄漏探测器(它只在存在第二个 cordis 预设时才触发,单预设的常见情况下泄漏是完全静默的;触发时它也不说「有个代际泄漏了」,只说某个预设挂载失败并把用户堵住)。所以代际泄漏需要按它自己的标准(TODO 里写的「已加入的 agent 计数」)单独跟踪,不能指望一个不再发生的症状去提醒(#4675)。
8. 眼下的规避与相邻问题:无论你挂的是内置预设、自研的 DSH插件 还是第三方 DeepSeek插件,在拿到修复前操作规则都是——一个宿主进程只开一个 cordis 预设会话;重启后先创建/选中你想要的那个 cordis 预设,任何其他 cordis 预设的 standing mount 都不要先触发。相邻的组合层同源问题(profile 的用户 patch cordis.patch.yml 与被提升的 bundle 层插入同一个 entry id,导致启动时 loader 的 EntryGroup.update 抛 duplicate loader entry id)机制不同、层面不同,也值得一并排查。装/卸插件本身建议走 DSH Plugin Hub,遇到挂载期报错先看 插件加载失败排查 确认是作用域冲突还是依赖问题(#1415)。
DSH plugin 排查注意事项
先记住这不是数据损坏、进程内也没有绕过——它是一次顺序相关的挂载期冲突,重启或按正确顺序关闭再打开才是唯一恢复手段。 九条要点:
- 不是数据损坏:挂载期失败,会话日志里什么都没写;重启后同一会话干净恢复。
- 失败顺序相关:先挂载者胜出并持有四个 id,直到它的 fiber 被 dispose(通常意味着到进程结束)。
- 干净关闭会释放 id:泄漏只在异常拆除(某一轮被命令错误打断等)时产生。
- 进程内无绕过:动态插件沙箱是只读 facade,热补丁宿主服务会被 guard 拒绝。
- 别用「替换」:它把响亮的失败换成静默的能力丢失,且受害的是没错的那个预设。
- 也别只用引用计数:disposer 幂等,重复递减会误删活着的持有者;用 token 身份。
- 不同清单必须仍然抛错:共享只对清单等价的注册成立,否则真冲突会被静默吞掉。
- 删会话没用:standing mount 在内存里,关闭/归档会话不会卸载它。
- 界面可能什么都不显示:后端有完整
agent-preset-invalid,前端可能丢弃——先看后端日志。

来源:Discussion #1415、Discussion #4675、fix/cordis-inspect-provider-sharing、Discussion #902。
常见问题
没有,这是 DSH plugin 的挂载期失败,不是数据损坏——被拒绝的 resume 在会话日志里什么都不写,因为挂载在任何事件追加之前就失败了,所以从用户侧看会话「就是打不开了」,但 transcript 里没有任何痕迹。主机重启后同一个会话能干干净净地恢复,这也反证了唯一变量就是进程内的注册表状态,与会话文件、预设内容都无关(来源:Discussion #1415)。
因为 DeepSeek Harness 的 CordisInspectRegistryService 是**进程级单例**,它内部那张 providers Map 对重复 id 直接抛错;而 @deepseek-ai/dsh-tool-cordis 在每次预设挂载时都无条件注册四个第一方 provider(Service / Event / Builtin / Tool)。同时预设的 standing mount 是**进程生命周期**的:一旦创建就永不 dispose(recompose 只重绑、不卸载旧的)。于是先挂载者永久占住这四个 id,其余任何挂着同一行的预设此后一直失败(来源:Discussion #4675)。
那是个陷阱:在 DSH plugin 里「替换」会把响亮的失败换成静默的能力丢失。设想 A 挂载并注册 Service → B 挂载并**替换**该条目 → B 卸载时执行标准的「还是我的就删」,而条目确实还是 B 的,于是被删掉;但 A 仍然挂着、仍以为自己注册了四个 provider,实际上一个都没有——cordis_inspect 对**一个活着的预设**静默失效,任何地方都没有报错。这等于把一个响亮、即时、可 grep 的失败,换成一次静默、延迟、依赖顺序的能力丢失,而且活下来的是那个什么都没做错的预设(来源:Discussion #4675)。
部分修了:原始报告里那位同学在 2026-08-21 确认「可以正常打开冲突的 agent 会话,不再需要绕行」,但 DeepSeek Harness 更广的「两个 cordis 预设共存」场景在 0.1.1-rc.2 与 0.1.2-alpha.1 上仍可复现(2026-09-10 仍有跨版本确认)。社区分支 nokkies/dsh-upstream-patches @ fix/cordis-inspect-provider-sharing(基于 cd5ef8148 / 0.1.2-alpha.1)可用 git am -3 直接应用,它让**清单等价**的注册共享一个 holder 集合,而**清单不同**的同 id 注册仍然响亮失败(来源:Discussion #1415、fix/cordis-inspect-provider-sharing 分支)。
相关术语
- standing mount(常驻挂载)
- standing mount 是预设的进程级挂载实例。它在进程存活期间不会被卸载——`ensureStanding` 在预设文件 stamp 变化时只删除 standing map 里的指针并重挂新代际,被淘汰的旧代际 fiber 从不 dispose,其 `ctx.effect` 注册(含四个 Host inspect provider)因此永不 unwind。— https://github.com/deepseek-ai/deepseek-harness/discussions/4675
- inspect provider registry(inspect provider 注册表)
- inspect provider registry 是 `CordisInspectRegistryService` 持有的进程级单例 Map,键是 provider id(`Service` / `Event` / `Builtin` / `Tool`)。它承载的是进程级事实而非会话级事实,因此「第二个注册者复用第一个」在语义上才是正确的。— https://github.com/deepseek-ai/deepseek-harness/discussions/4675
- holder set(持有者集合)
- holder set 是 idempotent 注册的正确形态:每个 id 只保留一条条目,同时记录一组匿名的持有者 token;只有最后一个持有者 dispose 时才删除条目。用 token 身份而非数字引用计数,是因为 `ctx.effect` 的 disposer 按契约必须幂等,计数被减两次会误删仍然活着的持有者。— https://github.com/deepseek-ai/deepseek-harness/discussions/4675
来源
- deepseek-harness Discussion #1415:Session resume fails — Host Cordis inspect provider "Service" is already registered· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #4675:tool-cordis inspect providers are process-global — two cordis-based presets cannot coexist· deepseek-ai(GitHub Discussions)
- 社区修复分支 nokkies/dsh-upstream-patches @ fix/cordis-inspect-provider-sharing(基于 cd5ef8148 / 0.1.2-alpha.1)· GitHub(nokkies)
- 同源问题对照:deepseek-harness Discussion #902(热重载 preset 后无法新建会话)· deepseek-ai(GitHub Discussions)