Main Chain A · Step 03

boot():条目表变成活的插件树

这一页进入 Cordis 运行时。boot()(packages/boot/app-boot/src/index.ts:970)把 02 页的条目表挂成一棵活的插件树:每个条目变成一个 fiber,插件在 fiber 上注册服务、挂事件监听、起副作用。

02 profile.ts:841 composeEntries → app-boot/src/index.ts:787 boot() → vendor/cordis/src/context.ts Context · registry.ts · fiber.ts → (下一步) 04 runProfile 收尾

示例本次示例:dsh web 挂出的真实插件树(节选)

示例轨迹 03-1 · pnpm dsh --profile web --dump-config 的真实输出开头
# == @deepseek-ai/dsh-base
- id: timer
  name: '@deepseek-ai/cordis-plugin-timer'
# == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app
- id: hmr
  name: '@deepseek-ai/cordis-plugin-hmr'
  config:
    root:
      - .
  disabled: true          ← web-app 把 hmr 禁掉了(04 页 279 行补 watch-only 的原因)
# == @deepseek-ai/dsh-base
- id: llm
  name: '@deepseek-ai/dsh-llm'
- id: session
  name: '@deepseek-ai/dsh-session'
- id: typert
  name: '@deepseek-ai/dsh-typert-registry'
- id: typert-loader
  name: '@deepseek-ai/dsh-typert-loader'
- id: typert-gateway
  name: '@deepseek-ai/dsh-api-gateway'
- id: session-title
  name: '@deepseek-ai/dsh-session-title'
  config:
    fallbackMaxWords: 5
    fallbackMaxBytes: 40
    maxTitleBytes: 80
...

# 共 135 个条目(base 78 行 + web-app 的 UI 条目 + 平台禁用等)
# 每个 - id: 一行就是一棵将要挂载的 fiber
来源:本机真实执行 pnpm dsh --profile web --dump-config(135 个条目)。注释 # == 行标出每个条目来自哪一层——这就是 03 页 boot() 之前 composeEntries 的产物
示例轨迹 03-2 · boot() 对这份输出的下一步
# boot(NAME, rootConfig, patches, prepare)
# 764 行:  const ctx = new Context()        ← 所有 135 个 fiber 的宿主
# 771 行:  await ctx.plugin(Loader)         ← 装配置→fiber 的引擎
# 774 行:  await mountRootInclude(...)      ← 挂载上面 135 个条目
# 782 行:  await ctx.get('loader')?.await() ← 等 135 个 fiber 全部 settle
# 784 行:  await assertEntriesActivated(...)← 有 FAILED/PENDING 就 fail-loud
# 如果一切正常:135 个插件已激活,注册了 ctx.llm / ctx.shell / ctx.tools / ...
来源:app-boot/src/index.ts:787-817(03 页 §1 的行级解读)

下一站:04 runProfile——看这棵树怎么被信号与热更新守护。

§1boot():九行编排

packages/boot/app-boot/src/index.tsboot 的主体(节选)970-1038
970export async function boot(
971-976  binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?,
977): Promise<Context> {
977  const ctx = new Context()
978  const startupLogs: StartupLogRecord[] = []   // 0.1.6-alpha.2 新增启动日志收集
980  const diagnostics = new Context()   // 独立 context:要活得比 root disposal 长
981-987  diagnostics.logger.exporter({ levels: { default: 2 }, export: ... })   // 只收 warn/error
990  let stage = 'host preparation failed'
991  try {
992    ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
993    ctx.provide('dshHomePath', dshHomePath)     // cordis.patch.yml 里 !!js 可用的值
996-998    // 拦下 internal/update 的 restart promise 并记录失败
999    await ctx.plugin(Loader)                    // 挂 Loader 服务
1000    await prepare?.(ctx)                     // 宿主预备:在配置树条目挂载前跑
1001    stage = 'plugin tree failed to load'
1002    await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)0.1.7:多了 binName
1008    await ctx.get('loader')?.await()          // 等整棵树 settle
1009    if (ctx.get('loader') === undefined) return ctx  // 树已被 surface 主动销毁
1010    await auditStartupEntries(ctx, binName)
1011    return ctx
1012  } catch (cause) {
1016    await ctx.fiber.dispose()      // 失败时销毁半成品树
1017-1020    if (cause instanceof StartupError) {   // 把启动日志挂给调用方见 00 页 §5
1018      cause.startup = { configurationPath: absoluteConfigPath, messages: startupLogs }
1023    let deepest: unknown = cause
1024    const seen = new Set<Error>()   // 环状 cause 也要能终止
1029    const stack = deepest instanceof AggregateError ? ... : ...
1032    throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
1034-1036  } finally { await diagnostics.fiber.dispose() }   // 收集器最后收摊实现「活得比 root 长」
1037}
977-987

整个 dsh 的运行时根在这里诞生:new Context()——Cordis 的依赖容器(§2 详述)。之后的一切(Loader、插件树)都挂在这一个对象上。

992

baseUrl 是配置文件的目录(转成 file:// URL)——Loader 用它解析配置里的相对 specifier。这就是 02 页说的「空 cordis.yml 只为锚定 baseUrl」的锚定点。

800

dshHomePath 是 ctx.provide 的第一个值——它是 cordis.patch.yml 里 !!js 表达式可以引用的变量。provide 在配置树条目挂载之前,所以所有插件解析启动期环境值时读到的都是同一个不可变快照。

801

ctx.plugin(Loader):Loader 是 Cordis 的一个服务(vendored,vendor/loader/src/index.ts),名字 'loader'。它是「把配置行变成 fiber」的引擎——ctx.loader.create({...}) 就是往树里挂一个新插件。

1000

prepare 回调:宿主预备。04 页的 runProfile 用它 provide 环境快照和命令行参数——时机在「Loader 装上之后、任何配置树条目挂载之前」,保证所有插件读到的环境值一致。

803

两段式错误标签:prepare 抛错是「host preparation failed」(宿主的问题);之后抛错是「plugin tree failed to load」(插件树的问题)。诊断信息一眼定位故障归属。

804

mountRootInclude:把 Include 服务(vendored cordis-plugin-include)挂为 id='include' 的 cordis:include builtin。它做的事:读 root 配置 + 把 patches overlay 叠上去。到这里,02 页的条目表终于变成了「会被挂载」的配置。

812

loader.await():等所有条目 settle。挂载是异步的(插件可以互相等待服务可用),这里阻塞到整棵树稳定。

813

一个微妙分支:surface(应用本体,比如 headless 的 one-shot runner)可能在树还没挂完时就完成了任务并 dispose 整棵树——此时 loader 服务已随树销毁,get('loader') 是 undefined,直接返回。这是「快速 one-shot 的正常退出」,不是错误。

1010

最终审计:auditStartupEntries(§4)——0.1.6 起分级:必需条目失败才抛错(抛的是 StartupError),可选条目失败只警告。

978-987

0.1.6-alpha.2 新增:启动日志收集器。一个独立的 Context(927 行)挂 logger exporter,只收 warn/error 级别。注释点明关键约束——「The collector must outlive root disposal to retain asynchronous cleanup errors」,所以它在 1034-1036 行的 finally 里才 dispose,而不是跟着 root 一起走。

1017-1020

把日志挂给调用方:若是 StartupError,把配置路径与收集到的日志挂到 cause.startup 再 rethrow。其余错误走原来的路径(1023-1033 行)——它们没有结构化的插件诊断可挂。apps/cli 的 reportStartupFailure 就是这些字段的消费者(见 00 页 §5)。

996-998

新增 internal/update 拦截:Cordis 的 Fiber.update() 会丢弃 restart promise,这里把它接住并记录失败。注释原文:「Fiber.update() discards the restart promise. Observe it before the waterfall returns」——否则热重载期间的 restart 失败会静默消失。

1012-1033

失败路径:先 dispose 半成品树(Cordis fiber 的 dispose 是幂等的,重复调用返回同一个已结算结果,所以这个 await 不会 reject 并盖掉 cause),然后挖错误链最深的 cause 拼进诊断——启动报错要带「真正的失败点」的栈,而不是包壳链。

§2Context:运行时根

boot() 里 new Context() 的这个 Context 定义在 vendor/cordis/src/context.ts(146 行)。它是被 Proxy 包裹的依赖容器:ctx.get('llm') / ctx.plugin(X) / ctx.provide(k, v) / ctx.on(event, fn) 都在这里。具体机制分布在三个 vendored 文件:

文件职责关键符号
vendor/cordis/src/context.tsContext 类本体:DI 容器 + 作用域(extend/isolate)+ 拦截(intercept)Context、baseUrl、extend、isolate
vendor/cordis/src/registry.ts注册机制:plugin/inject/get 的实现RegistryService、Inject、Plugin
vendor/cordis/src/fiber.ts插件实例的生命周期:状态机 + 可逆副作用 + 销毁Fiber、FiberState、CordisError
vendor/loader/src/index.tsLoader 服务:配置行 → fiberLoader、builtins
vendor/loader/src/config/entry.ts配置行的运行时形态Entry、EntryOptions
vendor/include/src/index.ts补丁语义:!!js 方言 + applyEntryPatchesentryListSchema、PatchOptions、Include

在 dsh 的代码里,你每天会看到三种 Context 用法:

  1. 服务获取:ctx.get('shell')、ctx.llm.stream(...)(类型层由 declaration merging 提供)。
  2. 服务注册:class X extends Service { constructor(ctx) { super(ctx, 'name') } }——注意注册点是 Service 基类,不是 ctx.provide()。
  3. 作用域:ctx.extend({ agent: this }) 产生 agent 作用域(07 页),ctx.isolate(...) 产生隔离 realm(15 页)。

§3fiber:条目的生命周期

配置树里每个 Entry 挂载后就是一个 fiber(vendor/cordis/src/fiber.ts)——插件实例的生命周期载体。FiberState 枚举是审计的依据:

状态含义boot 审计如何处理
ACTIVE插件已激活,注册与副作用生效中正常
PENDING还在等依赖(inject 的服务未就绪)报错并列出缺失的服务名
FAILEDinit 抛出,进入失败await 后取其 rejection 原因报错

dsh 代码里到处可见的「可逆副作用」契约就在这里:插件的一切注册(服务、事件监听、effect)都登记在自己的 fiber 上,fiber 销毁时全部逆转。这就是为什么换 provider 不需要重启进程——销毁旧 fiber 就把它的所有注册撤干净了。

§4启动审计:必需 vs 可选

packages/boot/app-boot/src/index.tsauditStartupEntries 全文923-937
923export async function auditStartupEntries(
924  ctx: Context,
925  binName: string,
926  warn: (line: string) => void = line => void process.stderr.write(line),
927): Promise<void> {
928  const failures = await inactiveEntries(ctx)
929  const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx)单个 Set 取代两个数组
930    || requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
931  if (required.size > 0) {必需失败 → 抛 StartupError
932    throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
933      id: entry.options.id, module: entry.options.name, required: required.has(entry), fiberState: ..., outcome,
934    })))
935  }
936  if (failures.length > 0) warn(activationDiagnostic(binName, failures))全为可选 → 只警告
937}
923-927

auditStartupEntries(0.1.6-alpha.1 从 assertEntriesActivated 改名而来),第三个参数 warn 默认写 stderr。改名不只是措辞——「assert」表达的是「不合规就崩」,「audit」表达的是「审计并分级处理」,这正是行为的变化。

928

inactiveEntries(822-860 行)收集所有未激活条目。「收集」与「处置」拆成两个函数——收集只描述事实(每条带 entry 和 outcome),处置才决定严重性。

929-930

0.1.6-alpha.2 的简化:旧版用 required/optional 两个数组分拣;现在只建一个 required Set——「哪些是必需的」是唯一需要判定的信息,其余自然是可选。判据两条:失败条目是 bootstrap Include 本身、或 id 在 requiredStartupEntryIds 白名单里。

931-935

抛 StartupError 而非普通 Error(0.1.6-alpha.2 改):除了人类可读的消息(startupDiagnostic),还携带结构化的 entries——每条含 id、module、是否必需、fiber 状态、失败原因。这让上层能做机器可读的处理,而不只是打印。

936

全是可选失败就只警告。这是 0.1.6-alpha.1 引入的松动:一个你没在用的可选插件失败不该阻止整个 harness 启动。警告仍可见,没有静默。

版本名称白名单项数失败处理
0.1.6-alpha.1auditStartupEntries9必需抛普通 Error,可选 warn(两个数组分拣)
0.1.6-alpha.2auditStartupEntries7必需抛 StartupError(带结构化 entries),可选 warn(单个 Set 判定)

白名单(744-752 行)现在是 agent-loop、webserver、modules、connection、headless-runner、acp、sdk-jsonrpc-server——比 alpha.1 少了两项。

这条改动的设计含义:dsh 的插件树是「配置驱动」的——用户可以 disable 行、可以不给可选插件装依赖。旧的全有或全无审计把「配置错误」和「应用残缺」当成一回事。现在的分界线是:少了它,dsh 还是不是 dsh?是则警告,否则崩。

与 00 页 §5 的接力:这里抛出的 StartupError 会被 bin.ts 接住(bin.ts:45-49),交给 reportStartupFailure 把完整报告写进 $DSH_HOME/logs/。而报告里的启动日志正是 03 页 boot 里那个收集器(925-934 行)捕获的——三个文件合起来才构成完整的失败诊断链路。