cordis.yml 组合:空根与五层 patch 栈
用户想改一行配置,为什么不该动 cordis.yml?因为它每次启动都被重写成空数组 []——整棵接线不是写出来的,是五层 patch 叠出来的:bundle layers → profile 用户层 → home 层 → --patch overlays → telemetryPatch。patch 整行替换 config 不 merge,行顺序不承载语义,同 id 后写赢。
Written in Chinese
第 0 秒,一个用户想把某行配置关掉。他打开 cordis.yml 改了一下,重启——改动没了。为什么?因为这个文件每次启动都被程序重写成一个空数组 []。你不该动根文件,你该动 cordis.patch.yml——整棵接线根本不是写在一个文件里的,而是五层 patch 在启动时叠出来的。这一章就讲这五层各是什么、谁压过谁、patch 的语义为什么是”整行替换”而不是”合并”。
这章接第 37 章:那章讲 Context 上每个能力名字怎么接上线;这章讲这张地图上的名字和实现,是怎么从磁盘上叠出来的。
空根哲学:为什么根文件故意是空的
看源码里根配置长什么样:
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
export const PROFILE_ROOT_FILENAME = 'cordis.yml'
注释自己就警告了:“Edit cordis.patch.yml, not this file.” 为什么这个文件每次启动都被重写?prepareProfile 的注释讲透了:“The root is always rewritten: the whole composition is patch layers, and the vendored Loader’s tree write-back (a plugin self-disposing persists the current tree) can bake composed rows into this file — which would duplicate every bundle insert on the next boot.”
也就是说:Loader 有个回写行为(插件自拆时会把当前树持久化)。如果不每次清空,组合出来的行会被”烤”进根文件,下次启动再叠一次——所有 bundle 行翻倍。根文件存在的唯一理由是给 Loader 一个真实文件作 baseUrl 锚点;它不是配置,是锚点。
本节源码依据(3 处)
五层 patch 栈:谁压过谁
readProfilePatches 是组合的核心。它读的栈按应用顺序是:
const patches = structuredClone([
...profile.layers.flatMap(layer => layer.patches), // ① bundle layers
...(loadOptionalPatches(binName, context.patchPath) ?? []), // ② profile 用户层
...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []), // ③ home 层
...context.overlays, // ④ --patch overlays
])
const telemetryPatch = resolveTelemetryPatch(...) // ⑤ telemetryPatch 最后 push
| 层 | 文件 | 语义 |
|---|---|---|
| ① bundle layers | dsh.profile.bundles 列出的各 bundle 的 patch | 基座:base bundle 用一个大 insert 把全部共享行铺出来 |
| ② profile 用户层 | <profile>/cordis.patch.yml | 这个 profile 的本地覆盖 |
| ③ home 层 | $DSH_HOME/cordis.patch.yml | 机器级偏好,压过单 profile 层 |
| ④ overlays | --patch 命令行参数 | 本次启动临时覆盖,不写盘 |
| ⑤ telemetryPatch | 程序生成 | 最后追加,关掉 telemetry 行 |
注意 ③ 的设计:为什么机器层压过 profile 层?注释说”machine-local preferences that apply to every profile, so it outranks the per-profile layer”——一台机器上的偏好(比如统一代理)应该覆盖单个 profile 的设置。
composeProfile 把这堆冻结成一次启动的 ComposedProfile;runProfile 再把 profileContext(含 profile 名、目录、home、overlays、telemetryDisabledEnv)provide 进 Context——插件 inject 它即知自己在哪个 profile 里跑。
本节源码依据(5 处)
patch 语义:整行替换,不合并
packages/bundle/base/cordis.patch.yml 开头的注释把语义钉死了:
“A patch replaces the targeted row’s whole
configrather than merging into it, so a row whose value differs by mode does NOT live here: it belongs to each mode bundle.” “Row order carries no load semantics (activation is service-availability driven); the grouping is for readers.”
两条硬规则:
- 整行替换 config:你只想改某行 5 个 key 里的 1 个?不行——你必须把 5 个全部重述。这就是为什么”按模式变化的行”不放 base bundle,而放各 mode bundle 自己。
- 行顺序不承载语义:分组只是给人读的。插件 activation 由服务可用性驱动(第 37 章的 inject 等待),不是由 YAML 里排第几行驱动。
同 id 后写赢:五层栈里后面的层命中同 id,就覆盖前面的。
本节源码依据(4 处)
!!js:同一文件里的条件行
条件分支不用拆文件——!!js 标量在挂载时求值:
- id: plugin-manager
name: '@deepseek-ai/dsh-plugin-manager'
disabled: !!js "!ctx.get('profileContext')"
plugin-manager 只在”由 dsh profile 启动”时加载;被嵌进别的应用时 ctx.get('profileContext') 是 undefined,该行自动 disabled。同一套组合既能独立跑 CLI,又能被嵌入宿主——靠的就是条件行,而不是两份配置。
本节源码依据(2 处)
telemetry 开关:off-by-mistake 优先
最后一层 patch 是程序生成的:
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '' || !hasRow) return undefined
return { id: TELEMETRY_ROW_ID, disabled: true } // 'session-telemetry-otel'
}
注释把隐私取向写得很清楚:“ANY non-empty value (including '0'/'false') disables: a privacy switch prefers off-by-mistake over on-by-mistake.” 设了 DSH_TELEMETRY_DISABLED=1 或者随便填个 no,telemetry 行就被 patch 成 disabled。另外:如果组合里根本没有 telemetry 行(自定义 profile 没装它),patch 不生成——开关不该变成启动错误。
把前面几章接回这张栈
回头看前面章节引用过的行,它们都躺在这同一张 base bundle 里:
| 行 id | 是谁 | 章节 |
|---|---|---|
tool-subagent-control | 全局 send_message 工具注册者 | 第 34 章 |
tool-subagent / tool-subagent-fork | spawn / fork 两种子代理入口 | 第五部 |
ptc-runtime | dsh-ptc-runtime-node(NodePtcRuntime 落地) | 第 35 章 |
workflow-ptc | PtcWorkflowEngine,provider: spawn | 第 35 章、第 37 章 |
base bundle 对 tool-subagent-control 的注释还解释了分工:“Continuable background children are selected per delegation tool. The separately loaded follow-up tool registers the one global send_message.”——委派工具(tool-subagent)和后续消息工具(tool-subagent-control)是两行,各管一段。
本节源码依据(3 处)
启动窗口的细节:信号、代理与 loud failure
组合的最后一公里不是”配置对了就开机”——runProfile 在挂第一行插件之前还做了几件与组合同等重要的事:
第一件:代理先于一切请求装上。 Node 的 fetch 自己不认代理环境变量,所以每个 profile 启动第一件事是 installProxyFromEnvironment(options.environment, ...)——从启动快照(而不是当时的 process.env)读代理。注释说这是为了让 .env 层声明的代理生效。任何插件发请求之前,代理必须在位。
第二件:信号从启动窗口第一天就归 shutdown 管。 注释原文:“Signals own teardown throughout the startup window, not only after boot() settles: an inserted provider can publish before sibling rows finish mounting.” 想想为什么——如果信号只在 boot 完成后才接管,那启动中按 Ctrl-C 就没人收尾了;而某个插入的 provider 完全可能在兄弟行还没挂完时就发布服务。SIGTERM 按 0 退出( supervisor 的常规停止),SIGINT 按 130 退出(用户中断)。
第三件:失败要响。 installFailLoud 挂上后,未捕获异常/未处理拒绝都会先 fiber.dispose() 再炸出来——不能让启动期的错误被吞进静默。
启动失败时的清理顺序同样写死:dispose() 把 app.current.fiber.dispose() 和 disposeProxy 两个释放放一个 Promise.all 里,各自 catch;单失败直抛,多失败聚成 AggregateError('dsh: profile cleanup failed')。启动失败的清理本身失败,也按聚合纪律处理——和第 36 章的拆除失败聚合同构。
本节源码依据(3 处)
改配置速查:动哪个文件
把这章收进一张实操表:
| 我想… | 改哪里 |
|---|---|
| 关掉某个 profile 里的一行 | <profile>/cordis.patch.yml(加 - id: 某行, disabled: true) |
| 给某行换配置 | 同文件整行重述(整行替换,不 merge) |
| 对这台机器所有 profile 生效 | $DSH_HOME/cordis.patch.yml(压过单 profile) |
| 只这一次启动改一下 | --patch /path/overlay.yml(不写盘) |
| 加新的默认能力层 | bundle 包的 patch(dsh.profile.bundles 列出) |
| 关 telemetry | 环境变量 DSH_TELEMETRY_DISABLED=1 |
| 别——每次启动被清空重写 |
小结
- 根文件是空的锚点:每次启动重写,改配置永远动
cordis.patch.yml。 - 五层栈:bundle layers → profile 用户层 → home 层 → overlays → telemetryPatch;home 压过 profile。
- 整行替换不 merge:改一个 key 要重述全部;行顺序无语义。
- !!js 条件行:同一套组合 CLI 独立跑、嵌入宿主跑两不耽误。
- telemetry off-by-mistake:任何非空值都关。
下一章:第 39 章——发现 skills 之后,它们是怎么作为这棵组合树的一部分被注入进模型上下文的。