dsh-conflict-doctor
插件冲突治理:把「装上了但没生效」变成一条可执行的结论。
DSH 有大量手段告诉你插件装上了;没有任何手段告诉你插件没在工作。三类真实故障全部是静默的:
| 现象 | 实际发生的事 | 现有工具为什么看不见 |
|---|---|---|
| 装完插件,功能不出现 | fiber 停在 PENDING,等一个没人提供的服务,不报错、不打日志 |
没有报错就没有信号 |
| 装第二个插件后整个功能没了 | tool id 重复 → 后注册者抛错 → 整行加载失败,错误信息不指出另一个 owner | 错误只说「already registered」 |
| 「我明明关了它,怎么还生效」 | 后一层 patch 把前一层的 disabled/config 盖掉了 |
层语义是整段替换、最后一层胜出 |
本插件把运行期图谱与磁盘上的 patch 层对齐,给出谁和谁撞了 + 改哪一行。
它检测什么
运行期(live)
| code | 严重度 | 含义 |
|---|---|---|
live/tool-id-conflict |
critical | 两个插件注册同一个 tool id;从失败 fiber 的错误里认出冲突名,并确认它已在注册表中 |
live/failed-fiber |
critical | 任何启动失败的 fiber,带原始错误首行 |
live/pending-missing-service |
critical | fiber 停在 PENDING 且 inject 的服务无人提供——静默失败的根源 |
live/duplicate-entry-id |
critical | 两个模块占用同一个 Loader entry id(整树启动失败) |
live/unprovided-inject |
warning | 声明依赖的服务在整个进程里都不存在(下次重载就会挂) |
磁盘组合(profile)
| code | 严重度 | 含义 |
|---|---|---|
profile/duplicate-row-in-layer |
critical | 同一 patch 层里 row id 重复 |
profile/module-under-multiple-ids |
critical / info | 同一模块挂两次。config 不同 → info(dsh-tool-subagent 用 toolName: subagent / subagent_fork 刻意挂两次);config 相同或为空 → critical(真的挂重了) |
profile/disable-then-reinsert |
critical | 某层停用、后面的层又插进来 |
profile/missing-patch-file |
critical | bundle 声明的 dsh.bundle.patch 在磁盘上不存在 |
profile/tool-id-collision |
critical | 两个包在编译产物里声明了同一个 tool id。这是静态版本比对永远覆盖不到的能力级冲突 |
profile/peer-range-unsatisfied |
warning / info | 按包分组。已装版本低于声明最小值 → warning(宿主比插件旧,真会坏);只是声明更旧 → info。仅在版本权威性已确认时运行(见下) |
profile/host-import-unsatisfied |
warning | 插件从宿主包里 import 了一个当前宿主并不导出的符号。这是「peer 版本字符串匹配 ≠ API 存在」的具体形态。仅在版本权威性已确认时运行 |
profile/service-collision |
warning | 两个包注册同名服务。按 realm 过滤:浏览器侧包(dsh.client)不与宿主共享服务表,不参与比对 |
profile/row-config-replaced |
info | 多层依次替换同一 row 的 config(DSH 的层语义,符合预期时无需处理,所以只作提示) |
profile/dangling-override |
warning | patch 里的 - id: X 不对应任何已插入的 row(空操作,不报错) |
profile/node-engine-unsatisfied |
warning | engines.node 不满足当前 Node。仅在版本权威性已确认时运行 |
profile/bundle-unresolved |
info | dsh.profile.bundles 里的名字在 node_modules 里解析不到 |
运行期还有两条需要跨两半才能看出的:
| code | 严重度 | 含义 |
|---|---|---|
live/pending-self-dependent |
critical | fiber 等的服务正是它自己要提供的——自锁,永远不会激活。修复不是「装 provider」 |
live/outbound-tool-collision |
warning | 两个已加载的插件都声明了同一 tool id,而该 id 还没进注册表:冲突是确定的,只是被时序掩盖 |
版本权威性:一次真实的误报与修正
这是本工具最重要的一条设计约束,来自一次被实测证伪的错误。
打包的桌面应用从自己的 archive 提供 profile bundle(@deepseek-ai/dsh-base@0.2.0-rc.2),而 profiles/node_modules 里可能是另一代(0.1.0-rc.6)。早期版本直接拿磁盘副本当基准,于是产出了 7 条并不存在的冲突:声称社区插件 import 了宿主没有的符号、peer 范围不满足。实测把那些符号在运行宿主的 archive 里数出来——IconBranchOutlineRegular 7 处、resolveImageAttachmentAccess 13 处,全都在。
所以现在:插件启动时先向 pluginManager 问运行中的真实版本,再决定要不要做版本敏感的判断。
- 两边一致 →
authoritative,正常比对; - 不一致 →
stale-disk,跳过 peer / import / engines 三类检查,并在报告里说明原因; - 读不到运行版本 →
unknown,同样跳过。
宁可不报,也不拿错误的基准报。结构类检查(重复 row、双挂载、层覆盖、配置被替换)不依赖版本,任何时候都照常运行。
每条 finding 都带 refs(涉及对象)、evidence(证据,不是猜测)、impact(会怎么坏)和至少一条 fix;能用 patch 修的直接给出可粘贴的 YAML。
安装
从插件市场(Settings → Plugin Market)搜索安装,或直接:
dsh plugin --profile web add dsh-conflict-doctor
然后把包名加进该 profile package.json 的 dsh.profile.bundles:
{
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-conflict-doctor"] } }
}
或者不安装,直接用 overlay 验证组合层:
dsh --profile web --patch <path-to>/conflict-doctor/cordis.patch.yml --dump-config
关于名字。 npm 上的
dsh-plugin-doctor是另一个作者的工具,做的是「兼容性 / 安全性 / 隔离安装 / 回滚」。本插件做的是冲突诊断与修复——谁和谁撞了、为什么静默失效、改哪一行。定位不同但名字几乎相同,会在市场上造成安装歧义,因此本包定名为dsh-conflict-doctor。
使用
模型侧工具:
conflict_doctor { scope: "all" | "live" | "profile", profilePath?: string, format?: "markdown" | "json" }
用户侧命令:
/conflict-doctor
scope: "live"— 只看运行中的进程(Loader 树、fiber 状态、服务、tool 注册表)scope: "profile"— 只读磁盘(bundle 清单 → 每层 patch → row ledger)scope: "all"(默认)— 两者都有
设计取向
- 只读。 不注册服务、不改配置、不碰注册表,只读 + 报告。一个「治理」插件自己成为故障源是荒谬的。
- 只有一个硬依赖
tools。loader/commands/systemPrompt/pluginManager全部在用时ctx.get——一个诊断插件如果自己停在 PENDING 就是个笑话。注册全部包在ctx.effect()里,卸载时干净撤销。 - 宁缺毋滥。 每条检测器都在真实 profile 上跑过并调过阈值。真实数据把第一版的 23 条噪音收敛到可执行的少数几条;宁可不报,也不拿错误基准报。
- 纯函数内核。 采集(
live.ts/static.ts/source.ts)→ 检测(detect.ts)→ 渲染(render.ts)都是纯函数,可以用 fixture 单测,不需要启动 harness。38 项检查覆盖每个检测器的正例+反例、真实 profile 扫描、以及 Cordis 集成。
主动播报
有 critical 冲突时,插件会把它写进系统提示(systemPrompt.context,order 50),而不是等你来问。没有冲突时贡献空字符串——健康的组合为此付出 0 token。内容只报结论与第一条修复,并明确要求模型在用户提出相关问题前不要擅自改配置。
能力边界(老实说)
- tool id 归属无法 100% 归因。 dsh 的 tool 注册表不记录注册者,公开 API 也拿不到调用方 fiber。所以运行期只报告「冲突名 + 失败行」,跨插件的冲突归属由静态扫描(
defineTool({ name: '...' })字面量)补上。动态拼出的 tool 名不在静态视野内。 - 静默覆盖只能报症状。
profile/row-config-replaced里无法判断被丢掉的那层 config 是否必需——因为语义上就是整段替换。 - 版本敏感的检查会主动弃权。 当磁盘副本不是运行中的宿主时,peer / import / engines 三类检查直接跳过并说明原因(见上)。这是刻意的:一次被实测证伪的误报,比十次「可能有问题」更伤信任。
- 名称类静态提取只认字面量。
super(ctx, 'name')、provide('name')、defineTool({ name: '...' })能认;拼接出来的名字不能。所以报告措辞是「声明于编译产物」而不是「运行期已注册」。 engines/ peer 是证据不是因果。 范围不满足不等于一定坏,但它是「插件调了宿主里不存在的 API」的第一嫌疑人。!!js不求值。 审计按原样记录 profile 提供的表达式——求值等于在审计里执行用户代码。因此disabled: !!js ...这类条件停用无法判定真假,相关行按「未停用」处理。- fiber 错误文本读的是运行时记录的错误对象(
fiber._error,非公开字段),取不到时降级为「无错误文本」而不是抛错。
开发
pnpm install
pnpm build # tsc -> lib/
pnpm test # 38 项检查:fixture 检测器 + 真实 profile 扫描 + Cordis 集成
node test/check-metadata.mjs # 发布元数据:name / engines.dsh / files / peer 声明
test/run.mjs 分三段:Part A 每个检测器的正例/反例 fixture(含版本权威性闸门);Part B 对 $DSH_PROFILE_DIR 真实 profile 做静态扫描;Part C 把 apply() 装进真实 Cordis 容器,验证工具/命令/告警注册、execute 输出契约、以及卸载后 ctx.effect 干净撤销。
test/emit-report.mjs 用真实数据写出仓库根目录的 conflict-doctor-report.md。
路线图
- 把 findings 主动播报进系统提示 ✅ 已实现(
systemPrompt.context,无冲突时 0 token) - 服务级冲突:两个插件提供同一个服务名 ✅ 已实现(realm 感知)
- 对「属性级 API 缺失」做真实比对 ✅ 已实现(import ↔ 导出面),并在实测中修正了基准错误
- slot / 客户端侧冲突(需要 Client Provider 配合)
- 把版本权威性做彻底:直接从运行宿主的 archive / 解析器拿导出面,而不是拿到版本号就弃权——这样
stale-disk也能给出可信的 API 比对 live/outbound-tool-collision的时序预测:结合 fiber 的inject等待状态,预告「这一行一旦激活就会撞」
MIT
No comments yet. Be the first to write one.