
47f9438,完整提交为 47f943859bef60e4160492346772ded9b24f765a。先看一场配置冲突
假设 session-query-sqlite 这一行先由基础配置给出内存数据库和延迟打开;Web 配置又重写它;Profile 想换成持久化文件;本机的 home 配置再改一次;今天启动时,你还连续传了两个 --patch,最后一个要求临时关闭搜索。
现在到底哪个值生效?为什么只打开 Web Bundle 的 YAML,仍然回答不了这个问题?
先说结论:运行中的应用,是一组有顺序的 Patch 依次作用后的结果,不是预先打包好、从此不可修改的 Bundle。 越靠后的有效操作越晚执行,因而可以覆盖前面的目标行;
但“后来者赢”绝不等于把任意嵌套对象递归深合并。
真正需要记住的是先后顺序。沿下图从左向右读优先级:Bundle → Profile → home → 命令行 overlay;同一层里的 Bundle 保持 manifest 顺序,重复出现的 --patch 也保持命令行参数顺序。

图的右端还有启动器自己掌握的 tail。它会在前面已经存在 agent-presets 行时补入随安装发布的 preset root;也只会在遥测开关非空、且树里确实已有 session-telemetry-otel 行时追加禁用操作。它不会凭空选择 Bundle,更不会为自定义 Profile 创造遥测插件。
因此完整顺序是:manifest 中的 Bundle patches → Profile patch → home patch → 各个 CLI overlay → launcher tail → 最终 entries。
要排查覆盖,先问“最后一次命中这条 id 的操作在哪里”,而不是只问“最早是谁插入了它”。
把开头那场冲突沿顺序走一遍。base 先用 insert 放入 session-query-sqlite,同时给出 path 和 openAt。Web 层用同一个 id 命中它,并完整重述自己接管的配置。到这里,行的身份没变,配置的所有权已经移交给后层。
接着,Profile 可以把数据库换成团队约定的位置,home 可以再换成本机实际位置。若命令行先传入“启动时打开”的 overlay,随后又传入“本次保持关闭”的 overlay,右边那份最后命中,所以本次运行以它为准。这里的“最后”是数组位置,不是文件修改时间,也不是包版本高低。
还要注意:最后一个 CLI 文件如果只写一个 openAt,它得到的不是“前层路径加新开关”,而是一整块只有这个键的新 config。想同时保留路径,就必须在这一层把路径也写出来。launcher tail 不处理这条数据库行,因此不会再次改变答案。
这种追踪方式比“把所有 YAML 放在脑中合并”可靠得多。对任意 entry,先找到首次 insert,再按层序列出同 id 操作,最后检查 tail 是否有资格追加。来源链一旦排好,谁赢、丢了哪个键、为何出现 skipped patch,都会变成可以逐项验证的问题。
五步看清应用怎样出现
第一步,Bundle 提供可工作的默认层。packages/bundle/base/cordis.patch.yml 放共享底座;后置的 packages/bundle/web-app/cordis.patch.yml 或 packages/bundle/headless/cordis.patch.yml 再增加、关闭或替换面向具体运行方式的行。
第二步,Profile 决定采用哪些 Bundle、采用什么顺序,并带上自己的用户 patch。packages/boot/app-boot/src/profile.ts 的 PROFILE_TEMPLATES 给出两个当前默认组合:Web 是 base 加 web-app,Headless 是 base 加 headless。它主要用于首次创建,也为下面那个很窄的旧版迁移提供目标。它们是 shipped defaults,不是不可突破的产品边界;manifest 没有 dsh 段时,Bundle 列表甚至可以为空。
已有 manifest 通常就是事实来源,只有一个迁移例外:旧版 Headless 若仍精确写着安装曾经生成的 [base, web-app, headless] 三层组合,normalizeShippedProfile() 会把它改成当前的 [base, headless],并保留 manifest 的其他字段。只认这一模一样的旧组合;任何增删、换序后的 Bundle 列表都当作用户自有配置,绝不重写。用户明确列出的 Bundle 若解析不到、没有声明 patch,或 patch 文件损坏,启动仍必须报错;有意的空列表可以成立,写坏的非空列表却不能被当作空配置放过。
第三步,home 层叠在 Profile 之上,表达这台机器对所有 Profile 的本地选择。接着,每一个 --patch 都按 argv 中出现的次序追加;后写的文件可以覆盖先写的文件,不会先被揉成一个无序集合。
第四步,启动器先根据前四类输入得到行索引,再决定是否追加自己那两类受限 tail。apps/cli/src/profile-boot.ts 还会把 Profile 目录中的根配置写成空 entry 列表 []。这个根文件只是 Loader 的真实挂载锚点,不是用户默认配置的备份;每轮从空根重算,才不会把上次写回的结果和本次 Bundle insert 重复叠加。
根文件之所以仍要真实存在,是因为 Loader 需要它确定 baseUrl,让相对插件路径以 Profile 目录为锚点。用户真正应该修改的是 Profile 的 patch 文件。
把“路径锚点”和“用户输入”拆开,既保住模块解析语义,也让每次启动拥有同一个可复现的起点。
如果不重写空根,Loader 上次可能把组合后的树写回根文件。下一次启动再应用 Bundle insert,同一批默认行就会重复出现;更糟的是,人会误把衍生结果当成原始输入继续编辑。空根不是删配置,而是在配置系统里明确区分配方与产物。
第五步,最终 patch 列表准备好之后,启动路径才克隆工作数据、挂载树、等待 Loader settle,并审计每个启用行是否真的激活。能解析 YAML 不是成功:插件导入失败、初始化抛错或一直等待缺失服务,都必须让启动公开失败。
顺序已经可见,现在再给术语。Bundle 是发布一层默认 patch 的包;Profile 是列出有序 Bundle 并附带用户层的运行面配方;Patch 是对 entry 列表执行的有目标操作,可以插入新行,也可以按 id 命中已有行。三者组合出的 entries 才接近“应用”,任何单独一个 Bundle 都不是正在运行的应用。

第一段源码:顺序不是口头约定
apps/cli/src/profile-boot.ts 的 allPatches(),代码按字节连续:
它证明了什么?Bundle 层永远先进入数组,Profile 与 home 随后,overlays 最后。composeProfile() 先把 argv 文件装进 overlays,再按条件把 preset root 与 telemetry disable 追加到同一个数组尾部,所以图中的从左到右就是实际执行顺序。
这里还有两个容易漏掉的细节。profile.layers.flatMap() 保留 manifest 数组的 Bundle 顺序;patchFiles.flatMap() 保留多个命令行文件的 argv 顺序。优先级并不来自 npm 依赖图,也不来自人的猜测。
“覆盖”的准确含义
Patch 不是通用对象合并器。按 id 命中一行时,后层给出的 config 会替换那一整块相关配置,而不是递归保留旧对象中没写出的键。
例如旧配置是 { path: ':memory:', openAt: 'never' },后层若只给 { openAt: 'startup' },不能期待 path 自动继承。Web Bundle 的文件因此明确提醒:一个目标行决定接管哪些配置,就应完整重述它拥有的键。
若目标 id 不存在,按 id 的 patch 也不会神奇地变成 insert。系统会留下 skipped-patch 诊断;要新增插件,必须明确写出完整的 insert entry。这让“修改已有责任”和“引入新能力”保持可区分。
另一个关键是 clone discipline。packages/boot/app-boot/src/profile.ts 的 composeEntries() 从 [] 开始,把层按序铺平后先做 structuredClone(),再调用 patch 算法。apps/cli/src/profile-boot.ts 的 runProfile() 在真正 boot 前也克隆最终 allPatches(composed);热更新的每一代则重新读取 Profile 与 home 两份文件,再克隆完整组合。
为什么这么谨慎?insert 的行对象可能按引用进入工作树,后续 id patch 又会原地修改它。如果多轮启动或热更新复用同一批对象,上一轮的覆盖就可能污染 Bundle 默认值;用户删掉覆盖后,也回不到原来的底层配置。克隆不是为了禁止本轮修改,而是为了让下一轮仍从干净配方开始。
热更新也不是“把刚变的文件继续补到旧树上”。Profile patch 和 home patch 各有 watcher,但无论哪一边变化,组合函数都会重新读取两份最新文件,再放回固定的 Bundle 下方、固定的 CLI 与 tail 上方,生成新一代完整 patches。
这解决了一个很隐蔽的交错问题。假设先改 Profile,紧接着又改 home;若第二个 watcher 只携带自己的变化,系统可能拼出“旧 Profile 加新 home”。整代重读则得到“新 Profile 加新 home”。删除某条覆盖时,较低层默认值也会自然重新露出,而不是被旧工作树里的残值黏住。
packages/boot/app-boot/src/index.ts 的用户层更新最终一次替换根 Include 的完整 patches。这样同一代配置要么整体进入 Loader,要么公开失败;不会先改数据库行、隔一会儿再改工具行,让正在处理的请求撞见两代配置混在一起。
第二段源码:组合完,还要证明它活了
下面摘自 packages/boot/app-boot/src/index.ts 的 boot();这是挂载、等待与审计的连续片段:

它证明了什么?最终 patches 先整体挂到空根,Loader 完成生命周期结算后,assertEntriesActivated()逐行检查。显式 disabled 的行可以合法不活动;启用行若没有 Fiber、激活失败,或卡在缺少依赖服务的 pending 状态,都不是“部分成功”。
disabled 与 broken 必须分开看。Web 后层有意关闭 base 的某些默认行,是清楚表达“不在这个作用域提供该能力”;这种行没有活动 Fiber 很正常。启用行因为包找不到、初始化抛错或注入服务迟迟不出现,却表示配方承诺的能力没有兑现,错误里应当保留具体插件与等待中的服务名。
审计发生在 Loader settle 之后,也避免把正常的启动中间态误判为失败。等所有生命周期有机会完成,再检查仍未激活的行;若树在一次性任务完成后已经按要求整体退出,代码会先确认 Loader 是否仍存在,不会把正常关闭包装成配置错误。
启动成功后也不能放弃这条原则。初始化阶段之外若出现未处理 rejection,installFailLoud()仍负责让进程以失败状态退出。系统宁可公开暴露一棵树没装好,也不维持“进程还在、能力却缺了一半”的假健康状态。
这也补上了组合系统的另一半:patch 顺序只回答“多个配置输入谁覆盖谁”,activation audit 回答“覆盖后的依赖契约是否成立”。能替换默认组合,不代表可以忽略插件声明的 service、inject 与生命周期要求。
Web 与 Headless:默认答案,不是唯二答案
base Bundle 汇集会话、模型、工具、权限、持久化等共享宿主能力。Web 层在它上面增加长驻 HTTP 与浏览器运行面,并把一部分 per-agent 能力移到更细的 session/preset 作用域;Headless 层则增加 code runtime、启动参数 provider 和一次性 runner,不安装 Web host、server 与 UI。
所以 Web 可以概括为“长期宿主加每个 Session 的 Agent 组合”,Headless 则是“接收一次任务、直接驱动 Agent、产出结果后退出”。这只是仓库随附的两张推荐配方。用户可以改 manifest、换 Bundle、加入自己的 Bundle,也可以在后层替换行。
自由仍有边界。一个自定义 Bundle 必须能被解析,必须声明自己的 patch;目标行必须存在,配置表达式必须有效,最终插件还得满足依赖并成功激活。可替换的是组合,不是运行时契约。
三个常见误解
误解一:Bundle 就是运行中的应用。 不对。Bundle 只贡献有位置的一层 patch;Profile、home、CLI 与 launcher tail 都还没算进去。
误解二:后来者赢,所以嵌套配置会自动深合并。 不对。目标行的相关 config 是整块替换;需要保留的键必须由接管这一行的层完整重述。
误解三:既然默认层可替换,随便删插件也能运行。 不对。Patch 可以重组树,却不会取消 service 依赖。最后的 activation audit 正是为了阻止一棵缺能力的半成品树伪装成成功应用。
小结
DeepSeek Harness 不把 Web 或 Headless 焊死成成品。它从空根出发,按 Bundle → Profile → home → CLI overlay → launcher tail 的顺序重算 entries;同 id 的后置操作优先,但配置整块替换,不做任意深合并。最终列表经过克隆后才进入 Loader,并以逐行激活审计收尾。
读这种系统,最有效的问题不是“主配置文件在哪”,而是“这条 entry 从哪一层插入,后来又被哪些层命中,最终是否真的激活”。顺着这三问走,配置冲突就从玄学变成了一条可追踪的来源链。
夜雨聆风