Ver Fonte

fix(boot): preserve failure policy on nontransactional Loader

turtle1999 há 3 semanas atrás
pai
commit
f4a7dd6077

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
-2026-09-09-consumer-owned-startup-strictness.md: b9ffe13901c2adabfe95f8ae3f44d8eba83b6d01
-2026-09-09-consumer-owned-startup-strictness.zh.md: f6aae3922407d04c472b3fde57ce7a4f4f1fb888
+2026-09-09-consumer-owned-startup-strictness.md: eab57329bc33c6a341d1073275deb4fd2355f041
+2026-09-09-consumer-owned-startup-strictness.zh.md: 687017533d114cd1e13205b50373fc415eb99c2a

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md

@@ -10,7 +10,7 @@ Best-effort Loader reconciliation preserves usable plugins, but applications sti
 
 ## Decision
 
-DSH owns startup strictness outside vendored Cordis. App-boot audits the settled initial tree against one global list of stable entry ids. A listed entry that is present, enabled, and not active rejects startup and disposes the application. A listed id that is absent or disabled has no effect. Every other inactive entry produces one warning and leaves successful siblings running.
+DSH owns startup strictness outside vendored Cordis. App-boot audits the settled initial tree against one global list of stable entry ids. A listed entry that is present, enabled, and not active rejects startup and disposes the application. A listed id that is absent or disabled has no effect. The bootstrap Include is required by entry identity because a missing or invalid root configuration prevents application assembly. Other inactive entries produce one warning and leave successful siblings running.
 
 The required ids are `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. They represent shared Agent execution and the endpoints of the shipped Web, headless, ACP, and SDK applications. Their injected providers do not need separate list entries: a missing provider leaves the listed endpoint pending or failed.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md

@@ -10,7 +10,7 @@ Best-effort Loader reconcile 会保留可用 plugin,但应用仍需一组最
 
 ## 决策
 
-DSH 在 vendored Cordis 之外持有启动严格语义。App-boot 用一份全局稳定 entry id list 审计已结算的初始 tree。List 中存在、启用且未 active 的 entry 会使启动 reject,并拆卸应用。List 中缺失或禁用的 id 不产生影响。其他 inactive entry 输出一次 warning,并让成功 sibling 继续运行。
+DSH 在 vendored Cordis 之外持有启动严格语义。App-boot 用一份全局稳定 entry id list 审计已结算的初始 tree。List 中存在、启用且未 active 的 entry 会使启动 reject,并拆卸应用。List 中缺失或禁用的 id 不产生影响。Bootstrap Include 按 entry 身份被视为 required,因为根配置缺失或无效会阻止应用组装。其他 inactive entry 输出一次 warning,并让成功 sibling 继续运行。
 
 Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。它们分别代表共享 Agent 执行,以及随附 Web、headless、ACP 和 SDK 应用的 endpoint。其 injected provider 不需要单列:provider 缺失会让已列出的 endpoint 保持 pending 或失败。
 

+ 52 - 0
apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts

@@ -175,6 +175,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
       rmSync(fixture.root, { recursive: true, force: true })
     }
 
+    expect(result.signal).toBeUndefined()
     expect({
       exitCode: result.exitCode,
       timedOut: result.timedOut,
@@ -229,6 +230,8 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
         timeout: 90_000,
         killSignal: 'SIGKILL',
       })
+      expect(result.timedOut).toBe(false)
+      expect(result.signal).toBeUndefined()
       expect(result.exitCode).toBe(1)
       expect(result.stdout).not.toContain('dsh web: http://')
       expect(result.stderr).toContain('required startup failure')
@@ -240,4 +243,53 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
       rmSync(root, { recursive: true, force: true })
     }
   })
+
+  it('fails and cleans up when detached work rejects after application startup', async () => {
+    const fixture = createFixture()
+    const plugin = join(fixture.root, 'detached.mjs')
+    writeFileSync(plugin, [
+      'export function apply(ctx) {',
+      '  ctx.effect(() => ctx.get("appReady").onReady(() => {',
+      '    void Promise.reject(new Error("detached Web failure"))',
+      '  }))',
+      '}',
+      '',
+    ].join('\n'))
+    writeFileSync(fixture.patch, readFileSync(fixture.patch, 'utf8') + [
+      '- insert:',
+      '    - id: detached-probe',
+      `      name: ${pathToFileURL(plugin).href}`,
+      '',
+    ].join('\n'))
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--patch', fixture.patch,
+        '--no-open',
+        '--port', '0',
+      ], {
+        cwd: fixture.root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-detached-no-call',
+          DSH_AGENTS_HOME: join(fixture.root, '.agents'),
+          DSH_HOME: fixture.home,
+          DSH_TELEMETRY_DISABLED: '1',
+          NODE_NO_WARNINGS: '1',
+        },
+        input: '',
+        reject: false,
+        timeout: 90_000,
+        killSignal: 'SIGKILL',
+      })
+      expect(result.timedOut).toBe(false)
+      expect(result.signal).toBeUndefined()
+      expect(result.exitCode).toBe(1)
+      expect(result.stderr).toContain('fatal load failure: Error: detached Web failure')
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
+    } finally {
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+  })
 })

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: 8b8b141a58ff4facfe4d07002464ef83a990f26f
-README.zh.md: 20f0d72f507e8ee5e347287c2d2f09cbf6995b5b
+README.md: 28d6cfd622947378367da59b1e6a897c8f9f2664
+README.zh.md: 593e3105bf50a3a04cf70a0df97e8f347081701b

+ 3 - 4
packages/boot/app-boot/README.md

@@ -68,14 +68,14 @@ After the Loader settles, app-boot classifies each enabled entry by stable id. O
 
 | Failure pattern | Entry result | Startup action |
 |---|---|---|
-| The root YAML cannot be read or parsed, is not an entry list, or repeats an id in one group | No valid candidate tree | Reject and dispose; no partial application is accepted |
+| The root YAML cannot be read or parsed, or is not an entry list | Bootstrap Include fails | Reject and dispose; no partial application is accepted |
 | A plugin module cannot be imported | Entry has no fiber | Warn if optional; reject and dispose if required |
 | Config expression evaluation or the plugin's config schema fails during activation | Fiber is `FAILED` with the validation error | Warn if optional; reject and dispose if required |
 | Synchronous `apply()` throws | Fiber is `FAILED` with the thrown error | Warn if optional; reject and dispose if required |
-| Asynchronous `apply()` rejects | Fiber is `FAILED` with the rejection | Warn if optional; reject and dispose if required |
+| Asynchronous `apply()` throws | Fiber is `FAILED` with the thrown error | Warn if optional; reject and dispose if required |
 | Required injected services never appear | Fiber remains `PENDING` and names the missing services | Warn if optional; reject and dispose if required |
 
-The Loader consumes activation rejections while reconciling the group, and app-boot awaits a failed fiber only to recover its recorded reason. The process-level fail-loud handler is reserved for detached asynchronous failures that no Loader entry owns.
+App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. Unrelated unhandled rejections remain fatal. Later config HMR reports failures without repeating the required-startup policy or restoring previous plugin config; a valid edit can recover the failed entry.
 
 If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it.
 
@@ -99,7 +99,6 @@ This section explains how the outcomes above are realized and points at the code
 - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
 - **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
-- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures.
 

+ 3 - 4
packages/boot/app-boot/README.zh.md

@@ -68,14 +68,14 @@ Loader 结算后,app-boot 按稳定 id 对每个已启用 entry 分类。Optio
 
 | 失败模式 | Entry 结果 | 启动措施 |
 |---|---|---|
-| 根 YAML 无法读取或解析、不是 entry list,或同一 group 内 id 重复 | 没有有效 candidate tree | 拒绝并拆卸;不接受部分应用 |
+| 根 YAML 无法读取或解析,或不是 entry list | Bootstrap Include 失败 | 拒绝并拆卸;不接受部分应用 |
 | Plugin module 无法 import | Entry 没有 fiber | Optional 时警告;required 时拒绝并拆卸 |
 | Config expression 求值或 plugin config schema 在 activation 时失败 | Fiber 为 `FAILED`,保留校验错误 | Optional 时警告;required 时拒绝并拆卸 |
 | 同步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 异步 `apply()` reject | Fiber 为 `FAILED`,保留 rejection | Optional 时警告;required 时拒绝并拆卸 |
+| 异步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
 | 必需的 injected service 始终未出现 | Fiber 保持 `PENDING`,并指出缺失 service | Optional 时警告;required 时拒绝并拆卸 |
 
-Loader 在 reconcile group 时消费 activation rejection;app-boot 只为取得已记录的原因而 await failed fiber。进程级 fail-loud handler 只处理不属于任何 Loader entry 的 detached asynchronous failure。
+App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。无关的未处理 rejection 仍然致命。之后的 config HMR 会报告失败,但不会再次应用 required 启动策略,也不会恢复旧 plugin config;有效修改可以恢复失败的 entry。
 
 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。
 
@@ -99,7 +99,6 @@ Loader 在 reconcile group 时消费 activation rejection;app-boot 只为取
 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
 - **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
-- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。
 

+ 13 - 11
packages/boot/app-boot/src/index.ts

@@ -272,7 +272,7 @@ export async function watchUserPatches(
     await ctx.loader.await()
     await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await())))
     const failures = await inactiveEntries(ctx)
-    if (failures.length > 0) ctx.logger.warn(activationDiagnostic(binName, 'warning', failures))
+    if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
   })
   try {
     return await register
@@ -732,8 +732,8 @@ function formatActivationError(error: unknown): string {
 }
 
 interface InactiveEntry {
-  /** Loader entry id used by the required-startup policy. */
-  id: string
+  /** Loader entry used to identify the bootstrap Include and required ids. */
+  entry: Entry
   /** Complete diagnostic beginning with the entry id and module specifier. */
   diagnostic: string
 }
@@ -751,7 +751,7 @@ async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
     if (entry.disabled) continue
     const subject = `${entry.options.id} (${entry.options.name})`
     if (fiber === undefined) {
-      failures.push({ id: entry.options.id, diagnostic: `${subject}: failed to import` })
+      failures.push({ entry, diagnostic: `${subject}: failed to import` })
       continue
     }
     const state = fiber.state
@@ -761,18 +761,18 @@ async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
         await fiber.await()
       } catch (error) {
         rejectionReasons.push(error)
-        failures.push({ id: entry.options.id, diagnostic: `${subject}: ${formatActivationError(error)}` })
+        failures.push({ entry, diagnostic: `${subject}: ${formatActivationError(error)}` })
       }
       continue
     }
     if (state === FIBER_PENDING) {
       const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
       failures.push({
-        id: entry.options.id,
+        entry,
         diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
       })
     } else {
-      failures.push({ id: entry.options.id, diagnostic: `${subject}: fiber state ${String(state)}` })
+      failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` })
     }
   }
   if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
@@ -796,11 +796,12 @@ function activationDiagnostic(
  * Inactive entries from the global required list reject startup. Other
  * inactive entries produce one warning and leave successful siblings running.
  * Required ids absent from the tree, and disabled required entries, are ignored.
+ * The bootstrap Include must activate so unreadable or invalid root config is fatal.
  * @param ctx - the settled context whose Loader entries to audit.
  * @param binName - the diagnostic prefix on optional-entry warnings.
  * @param warn - sink for optional-entry warnings.
- * @returns after all optional failures are warned when no required entry failed.
- * @throws when an enabled entry in {@link REQUIRED_STARTUP_ENTRY_IDS} is inactive.
+ * @returns after all optional failures are warned when required startup entries are active.
+ * @throws when the bootstrap Include or an enabled entry in {@link REQUIRED_STARTUP_ENTRY_IDS} is inactive.
  */
 export async function auditStartupEntries(
   ctx: Context,
@@ -811,7 +812,8 @@ export async function auditStartupEntries(
   const required: InactiveEntry[] = []
   const optional: InactiveEntry[] = []
   for (const failure of failures) {
-    const target = requiredStartupEntryIds.has(failure.id) ? required : optional
+    const target = failure.entry === bootstrapIncludes.get(ctx)
+      || requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional
     target.push(failure)
   }
   if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional))
@@ -843,7 +845,7 @@ export async function auditStartupEntries(
  * @param bareModuleBaseUrl - optional installed-host base for bare package
  * names; use it when the host, rather than the configuration project, owns the
  * complete plugin set.
- * @returns the root context once every entry has started, or as soon as a
+ * @returns the root context after the initial startup audit, or as soon as a
  * surface disposed the tree while startup was still in flight.
  * @throws a labelled error after disposing the partial context — `host
  * preparation failed` when `prepare` threw before any config-tree entry

+ 34 - 0
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -916,6 +916,40 @@ describe('boot', () => {
     }
   })
 
+  it.each([
+    ['missing', undefined, 'config file not found'],
+    ['malformed', 'invalid: [unclosed\n', 'unexpected end'],
+    ['non-array', 'entries: []\n', 'top-level array'],
+  ])('rejects a %s root configuration', async (_kind, content, message) => {
+    const dir = tmp()
+    const configPath = join(dir, 'cordis.yml')
+    if (content !== undefined) writeFileSync(configPath, content)
+    let ctx: Context | undefined
+    try {
+      await expect(boot(NAME, configPath).then((value) => { ctx = value })).rejects.toThrow(message)
+    } finally {
+      await ctx?.fiber.dispose()
+    }
+  })
+
+  it.each([
+    ['import', undefined, '', 'failed to import'],
+    ['config schema', 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate() { return { issues: [{ message: "schema failure" }] } } } }\nexport function apply() {}\n', '', 'schema failure'],
+    ['config expression', 'export function apply() {}\n', '  config: { value: !!js "JSON.parse(\'invalid\')" }\n', 'SyntaxError'],
+    ['sync apply', 'export function apply() { throw new Error("sync failure") }\n', '', 'sync failure'],
+    ['async apply', 'export async function apply() { await Promise.resolve(); throw new Error("async failure") }\n', '', 'async failure'],
+    ['missing dependency', 'export const inject = ["missingRequiredService"]\nexport function apply() {}\n', '', 'missingRequiredService'],
+  ])('disposes startup after a required %s failure', async (_kind, source, config, message) => {
+    const dir = tmp()
+    if (source !== undefined) writeFileSync(join(dir, 'required.mjs'), source)
+    writeFileSync(join(dir, 'cordis.yml'), `- id: webserver\n  name: ./required.mjs\n${config}`)
+    let disposed = false
+    await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
+      ctx.effect(() => () => { disposed = true })
+    })).rejects.toThrow(message)
+    expect(disposed).toBe(true)
+  })
+
   it('disposes successful entries and rejects when a required entry fails', async () => {
     const dir = tmp()
     let disposed = false

+ 65 - 0
packages/boot/app-boot/tests/config-reload.spec.ts

@@ -195,6 +195,71 @@ describe('include patches layered over one base', () => {
   })
 })
 
+describe('best-effort config failure recovery', () => {
+  it.each([
+    ['import', undefined, undefined],
+    ['sync apply', 'export function apply(_ctx, config) { if (config.fail) throw new Error("reload sync failure") }\n', 3],
+    ['async apply', 'export async function apply(_ctx, config) { await Promise.resolve(); if (config.fail) throw new Error("reload async failure") }\n', 3],
+    ['dependency', 'export const inject = ["reloadMissing"]\nexport function apply() {}\n', 0],
+  ] as const)('keeps siblings after a required-id %s failure during HMR', async (_kind, source, state) => {
+    const base = '- id: good\n  name: ./noop.mjs\n'
+    const { ctx, dir, include } = await bootTree(base, {
+      ...source === undefined ? {} : { 'failure.mjs': source },
+      'provider.mjs': 'export function apply(ctx) { ctx.provide("reloadMissing", true) }\n',
+    })
+    try {
+      const good = [...ctx.loader.entries()].find(entry => entry.options.id === 'good')!.fiber
+      writeFileSync(join(dir, 'cordis.yml'), base + '- id: webserver\n  name: ./failure.mjs\n  config: { fail: true }\n')
+      await include.refresh()
+      await ctx.loader.await()
+      const failed = [...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')!
+      expect(failed.fiber?.state).toBe(state)
+      expect(good?.state).toBe(2)
+      expect(ctx.fiber.state).toBe(2)
+
+      const recovery = `- id: webserver\n  name: ./${source === undefined ? 'noop' : 'failure'}.mjs\n  config: { fail: false }\n`
+      const provider = state === 0 ? '- id: provider\n  name: ./provider.mjs\n' : ''
+      writeFileSync(join(dir, 'cordis.yml'), base + recovery + provider)
+      await include.refresh()
+      await ctx.loader.await()
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')?.fiber?.state).toBe(2)
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === 'good')?.fiber).toBe(good)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('keeps the previous fiber config after schema rejection and retries a valid edit', async () => {
+    const config = (value: number | string): string => `- id: webserver\n  name: ./schema.mjs\n  config: { value: ${JSON.stringify(value)} }\n`
+    const { ctx, dir, include } = await bootTree(config(1), {
+      'schema.mjs': [
+        'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate(config) {',
+        '  return typeof config.value === "number" ? { value: config } : { issues: [{ message: "expected number" }] }',
+        '} } }',
+        'export function apply(ctx, config) { ctx.provide("validatedValue", config.value) }',
+        '',
+      ].join('\n'),
+    })
+    try {
+      writeFileSync(join(dir, 'cordis.yml'), config('invalid'))
+      await include.refresh()
+      await ctx.loader.await()
+      const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'webserver')!
+      expect(entry.options.config).toEqual({ value: 'invalid' })
+      expect(entry.fiber?.config).toEqual({ value: 1 })
+      expect(ctx.get('validatedValue')).toBe(1)
+
+      writeFileSync(join(dir, 'cordis.yml'), config(2))
+      await include.refresh()
+      await ctx.loader.await()
+      expect(entry.fiber?.config).toEqual({ value: 2 })
+      expect(ctx.get('validatedValue')).toBe(2)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+})
+
 describe('shipped builtins', () => {
   it('lets a booted composition share one isolate realm across a group of rows', async () => {
     // The reason `boot()` registers `cordis:group`: a composition — notably an

+ 20 - 16
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -178,15 +178,15 @@ describe('loadOptionalPatches', () => {
   })
 })
 
-function writeTree(dir: string): string {
+function writeTree(dir: string, id = 'noop', asyncApply = false): string {
   writeFileSync(join(dir, 'noop.mjs'), [
     'export const name = "noop"',
-    'export function apply(_ctx, config = {}) {',
+    `export ${asyncApply ? 'async ' : ''}function apply(_ctx, config = {}) {`,
     '  if (config.fail) throw new Error("candidate config failed")',
     '}',
     '',
   ].join('\n'))
-  writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n  config:\n    value: base\n')
+  writeFileSync(join(dir, 'cordis.yml'), `- id: ${id}\n  name: ./noop.mjs\n  config:\n    value: base\n`)
   return join(dir, 'cordis.yml')
 }
 
@@ -406,12 +406,16 @@ describe('boot with user patches', () => {
     }
   })
 
-  it('applies live patches, reports failures without rollback, and recovers after a later edit', { timeout: 20_000 }, async () => {
+  it.each([
+    { id: 'noop', asyncApply: false },
+    { id: 'webserver', asyncApply: false },
+    { id: 'webserver', asyncApply: true },
+  ])('keeps HMR best effort and recovers ($id, async apply: $asyncApply)', { timeout: 20_000 }, async ({ id, asyncApply }) => {
     const dir = tmp()
     const userDir = tmp()
     const filename = join(userDir, PROFILE_PATCH_FILENAME)
-    const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
-    const ctx = await boot(NAME, writeTree(dir), basePatches)
+    const basePatches = [{ id, config: { value: 'generated' } }]
+    const ctx = await boot(NAME, writeTree(dir, id, asyncApply), basePatches)
     onTestFinished(() => ctx.fiber.dispose())
     await ctx.plugin(Timer)
     await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
@@ -439,29 +443,29 @@ describe('boot with user patches', () => {
     expect(watchers).toHaveLength(1)
     const watcher = watchers[0]!
     try {
-      writeFileSync(filename, '- id: noop\n  config:\n    value: live\n')
+      writeFileSync(filename, `- id: ${id}\n  config:\n    value: live\n`)
       watcher.emit('add', filename)
-      await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'live', 'user patch addition was not applied')
 
-      writeFileSync(filename, '- id: noop\n  config:\n    fail: true\n')
+      writeFileSync(filename, `- id: ${id}\n  config:\n    fail: true\n`)
       watcher.emit('change', filename)
       await eventually(() => failures.length === 1, 'failed candidate was not reported')
       expect(failures[0]).toBeInstanceOf(Error)
-      expect(entryConfig(ctx, 'noop')).toMatchObject({ fail: true })
+      expect(entryConfig(ctx, id)).toMatchObject({ fail: true })
 
       writeFileSync(filename, 'invalid: [unclosed\n')
       watcher.emit('change', filename)
       await eventually(() => failures.length === 2, 'parse failure was not reported')
       expect(failures[1]).toBeInstanceOf(Error)
-      expect(entryConfig(ctx, 'noop')).toMatchObject({ fail: true })
+      expect(entryConfig(ctx, id)).toMatchObject({ fail: true })
 
-      writeFileSync(filename, '- id: noop\n  config:\n    value: recovered\n')
+      writeFileSync(filename, `- id: ${id}\n  config:\n    value: recovered\n`)
       watcher.emit('change', filename)
-      await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'recovered', 'valid recovery was not applied')
 
       unlinkSync(filename)
       watcher.emit('unlink', filename)
-      await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
       expect(failures).toHaveLength(2)
 
       // Default compose: the user layer IS the whole patch list, so a
@@ -470,9 +474,9 @@ describe('boot with user patches', () => {
       const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
       expect(watchers).toHaveLength(2)
       try {
-        writeFileSync(filename, '- id: noop\n  config:\n    value: identity\n')
+        writeFileSync(filename, `- id: ${id}\n  config:\n    value: identity\n`)
         watchers[1]!.emit('add', filename)
-        await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
+        await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
       } finally {
         await disposeDefault()
       }