Преглед изворни кода

Merge pull request #3830 from deepseek-harness/turtle/required-plugin-startup-policy

feat(boot): distinguish required startup failures
Turtle пре 1 недеља
родитељ
комит
239f695e1f
100 измењених фајлова са 1866 додато и 996 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml
  11. 35 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
  12. 35 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md
  13. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml
  14. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
  15. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md
  16. 6 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml
  17. 35 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md
  18. 35 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md
  19. 2 2
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml
  20. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
  21. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md
  22. 1 0
      apps/cli/src/profile-boot.ts
  23. 10 11
      apps/cli/tests/built-bin.e2e.ts
  24. 1 1
      apps/cli/tests/fixtures/invalid-provider.cordis.yml
  25. 4 2
      apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt
  26. 1 7
      apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml
  27. 16 8
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  28. 12 4
      apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts
  29. 8 4
      apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
  30. 2 1
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  31. 350 0
      apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
  32. 2 2
      apps/web/tests/scaffold.ts
  33. 2 2
      docs/cordis-api/fiber.i18n.yaml
  34. 3 3
      docs/cordis-api/fiber.md
  35. 3 3
      docs/cordis-api/fiber.zh.md
  36. 2 2
      docs/event-producer-consumer.i18n.yaml
  37. 1 0
      docs/event-producer-consumer.md
  38. 1 0
      docs/event-producer-consumer.zh.md
  39. 2 2
      docs/rescope.i18n.yaml
  40. 1 1
      docs/rescope.md
  41. 1 1
      docs/rescope.zh.md
  42. 0 1
      package.json
  43. 2 2
      packages/boot/app-boot/README.i18n.yaml
  44. 19 6
      packages/boot/app-boot/README.md
  45. 19 6
      packages/boot/app-boot/README.zh.md
  46. 2 2
      packages/boot/app-boot/package.json
  47. 137 63
      packages/boot/app-boot/src/index.ts
  48. 89 0
      packages/boot/app-boot/src/watch-config.ts
  49. 307 133
      packages/boot/app-boot/tests/app-boot.spec.ts
  50. 93 228
      packages/boot/app-boot/tests/config-reload.spec.ts
  51. 0 213
      packages/boot/app-boot/tests/hmr-config.spec.ts
  52. 63 43
      packages/boot/app-boot/tests/user-patches.spec.ts
  53. 286 0
      packages/boot/app-boot/tests/watch-config.spec.ts
  54. 2 2
      packages/bundle/web-app/README.i18n.yaml
  55. 1 1
      packages/bundle/web-app/README.md
  56. 1 1
      packages/bundle/web-app/README.zh.md
  57. 6 4
      packages/bundle/web-app/src/index.ts
  58. 37 2
      packages/bundle/web-app/tests/web-app.spec.ts
  59. 2 2
      packages/client/web/README.i18n.yaml
  60. 1 1
      packages/client/web/README.md
  61. 1 1
      packages/client/web/README.zh.md
  62. 2 2
      packages/client/web/src/boot-client.ts
  63. 9 5
      packages/client/web/tests/boot-client.client.spec.ts
  64. 2 2
      packages/extensions/cordis-client-runner/README.i18n.yaml
  65. 1 1
      packages/extensions/cordis-client-runner/README.md
  66. 1 1
      packages/extensions/cordis-client-runner/README.zh.md
  67. 3 1
      packages/extensions/cordis-client-runner/src/client/runtime.ts
  68. 25 5
      packages/extensions/cordis-client-runner/tests/runner.client.spec.ts
  69. 10 5
      packages/host/directory-picker-auto/src/index.ts
  70. 20 4
      packages/host/directory-picker-auto/tests/loader-composition.spec.ts
  71. 1 1
      packages/host/plugin-inventory/tests/inventory.spec.ts
  72. 5 9
      packages/host/webserver/tests/webserver.spec.ts
  73. 1 0
      packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts
  74. 2 2
      packages/mcp/mcp-client/README.i18n.yaml
  75. 1 1
      packages/mcp/mcp-client/README.md
  76. 1 1
      packages/mcp/mcp-client/README.zh.md
  77. 0 6
      packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml
  78. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  79. 1 1
      packages/preset/agent-presets/README.md
  80. 1 1
      packages/preset/agent-presets/README.zh.md
  81. 16 25
      packages/preset/agent-presets/src/mount.ts
  82. 23 9
      packages/preset/agent-presets/tests/mount.spec.ts
  83. 2 2
      packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts
  84. 2 2
      packages/test-support/client-runtime/README.i18n.yaml
  85. 0 0
      packages/test-support/client-runtime/README.md
  86. 0 0
      packages/test-support/client-runtime/README.zh.md
  87. 5 2
      packages/test-support/client-runtime/src/assembly/test-client.ts
  88. 18 1
      packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts
  89. 13 1
      packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts
  90. 1 0
      packages/todo/tool-todo/tests/loader-composition.spec.ts
  91. 2 2
      packages/typert/loader/tests/loader.spec.ts
  92. 3 3
      pnpm-lock.yaml
  93. 0 4
      pnpm-workspace.yaml
  94. 2 9
      scripts/rescope-vendor.ts
  95. 1 1
      scripts/run-gates.spec.ts
  96. 0 1
      scripts/run-gates.ts
  97. 0 72
      scripts/verify-vendored-links.ts
  98. 11 11
      vendor/README.md
  99. 1 1
      vendor/cordis/src/events.ts
  100. 3 3
      vendor/cordis/src/fiber.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: 21bad78792c6b5aad48b51f454f6c08c0400ad72
-2026-07-23-client-plugin-loading-model.zh.md: 2758f28f3bd34131ece3bed74152fbfe0b36174e
+2026-07-23-client-plugin-loading-model.md: c5576b148c5991dd498d3aed605e3c2e3395774b
+2026-07-23-client-plugin-loading-model.zh.md: c7d6982c2680995bd4698ddbff052a7708f69997

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -52,7 +52,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 **Host side — compose the graph.**
 
-1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
+1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -52,7 +52,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 **host 侧——组合这张图。**
 
-1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
+1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-07-24-web-config-tree-boot-and-transport-layering.md
-2026-07-24-web-config-tree-boot-and-transport-layering.md: e7c0781e1504bce12a8b0d2197bd47b37d3e873a
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 9a0affa397151a3994c5731bebda4b45a4139b6c
+2026-07-24-web-config-tree-boot-and-transport-layering.md: c8a1db3cd7bd715d8912b22a0b41a9ef660c1316
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 41fcf65b06829199c203faa806bd17a310b68bcf

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 
 ## Decision
 
-**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../../archived/simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
+**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../../archived/simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. `auditStartupEntries` reports failed imports, reads failed fibers for original activation errors, and lists services that leave a fiber `PENDING`. The [startup policy](2026-09-09-consumer-owned-startup-strictness.md) makes optional failures warnings and required failures fatal. Reported rejection reasons stay marked through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
 
 **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../../archived/simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的始终启用的 bundle 行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
+**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../../archived/simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的始终启用的 bundle 行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。`auditStartupEntries` 报告 import 失败、读取失败 fiber 的原始激活错误,并列出让 fiber 停在 `PENDING` 的服务。[启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md)将 optional failure 作为 warning,将 required failure 视为致命错误。已报告的 rejection 原因会保持标记至一个进程级检查点,使 `installFailLoud` 合并 Loader 的重复通知,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
 
 **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-08-03-per-session-agent-presets.md
-2026-08-03-per-session-agent-presets.md: 8af48979b49f08c8e3ac945f648acbb615757a98
-2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d
+2026-08-03-per-session-agent-presets.md: c2c9f670df480662804e086fbf150c773d8f4fc8
+2026-08-03-per-session-agent-presets.zh.md: 9a4760be9e4a9d1d2de0a7f8d2d3bce755c04480

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md

@@ -33,7 +33,7 @@ The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and `
 
 **The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
 
-**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it.
+**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `auditStartupEntries` cannot see it. The mount reads its own rows through an `Include` subclass that publishes the tree and requires every enabled row to activate, independently of the [application startup policy](2026-09-09-consumer-owned-startup-strictness.md).
 
 **A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md

@@ -33,7 +33,7 @@ Status: implemented
 
 **有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
 
-**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
+**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`auditStartupEntries` 也看不到它。挂载过程通过一个会公开自身 tree 的 `Include` 子类读取各行,并要求每个启用行都激活,不受[应用启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md)影响
 
 **preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。
 

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 8e3d5f0141245ba2fa2c85faba607ac1be952e37
+2026-09-09-consumer-owned-startup-strictness.zh.md: 3873b9ceeb6204939e817a53514f5e18767ececc

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

@@ -0,0 +1,35 @@
+# Agent Note: Consumer-owned startup strictness
+
+Status: implemented
+
+English | [中文](2026-09-09-consumer-owned-startup-strictness.zh.md)
+
+## Problem
+
+Best-effort Loader reconciliation preserves usable plugins, but applications still need a minimum set of capabilities. An HTTP application without its listening server is not running, while an unavailable tool can be omitted without making the remaining application unusable. Cordis cannot infer this distinction from plugin implementation or dependency state.
+
+## 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. 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, application endpoints, and Web bootstrap/transport. Web needs its client module registry and authenticated connection even when the HTTP server can listen without them. Providers already required through injection need no separate entry: their absence leaves a listed consumer pending or failed.
+
+The audit treats a throwing `disabled` expression as an entry failure, not a disabled entry, because evaluation never established whether to skip it. The same optional/required policy applies to that failure.
+
+The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair.
+
+This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-08-03-per-session-agent-presets.md) own a separate strict subtree audit.
+
+## Alternatives considered
+
+- **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group.
+- **Declare required entries in each profile.** Rejected because the same application endpoints would be duplicated across profile data and custom profiles. A global list treats missing ids as irrelevant while keeping stable shipped ids authoritative.
+- **Make every startup failure optional.** Rejected because a process that cannot expose its selected application endpoint must report launch failure.
+
+## Consequences
+
+Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root.
+
+## Testing
+
+App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.

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

@@ -0,0 +1,35 @@
+# Agent Note:由 consumer 持有启动严格语义
+
+Status: implemented
+
+[English](2026-09-09-consumer-owned-startup-strictness.md) | 中文
+
+## 问题
+
+Best-effort Loader reconcile 会保留可用 plugin,但应用仍需一组最小 capability。HTTP 应用没有 listening server 就不算运行,而一个 tool 不可用时可以仅省略该 tool,剩余应用仍然可用。Cordis 无法从 plugin 实现或依赖状态推断这一区别。
+
+## 决策
+
+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 执行、应用 endpoint,以及 Web 启动与传输。即使 HTTP server 不依赖它们也能监听,Web 仍需要客户端模块注册表和经过认证的连接。通过注入已成为必需项的 provider 不需要单列:它们缺失时,已列出的消费方会保持 pending 或失败。
+
+审计将 `disabled` 表达式抛出的异常视为 entry 失败,而不是 entry 已禁用,因为求值未能确定是否跳过它。该失败遵循相同的 optional/required 策略。
+
+该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。
+
+该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-08-03-per-session-agent-presets.zh.md)持有独立的严格子树审计。
+
+## 考虑过的替代方案
+
+- **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。
+- **在每个 profile 中声明 required entry。** 拒绝,因为相同应用 endpoint 会在 profile data 与 custom profile 中重复。全局 list 会忽略缺失 id,同时让稳定的随附 id 保持权威。
+- **把所有启动失败都视为 optional。** 拒绝,因为无法暴露所选应用 endpoint 的进程必须报告启动失败。
+
+## 后果
+
+稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。
+
+## 测试
+
+App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.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/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
-2026-07-31-fail-loud-releases-the-terminal.md: e5196121a850997b5eff045a26dd7c638776196c
-2026-07-31-fail-loud-releases-the-terminal.zh.md: 5cec9fe7d4ac75df1b5c88f2ea4cb0a665730719
+2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed
+2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md

@@ -15,7 +15,7 @@ $ 1;2;4cecho hello
 zsh: command not found: 4cecho
 ```
 
-The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
+The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The [Loader activation audit](../simplification/2026-09-09-nontransactional-loader.md) reports config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
 
 Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md

@@ -17,7 +17,7 @@ zsh: command not found: 4cecho
 
 Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。
 
-在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
+在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。([Loader 激活检查](../simplification/2026-09-09-nontransactional-loader.zh.md) 让配置树失败经 `boot()` 报告,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
 
 `/exit` 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/simplification/2026-09-09-nontransactional-loader.md
+2026-09-09-nontransactional-loader.md: 7ea4db0abf0fb65e443126dbca9fd822e47ce2d0
+2026-09-09-nontransactional-loader.zh.md: 5293efb58998f68a25e1143a2d043da32ef06bfb

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md

@@ -0,0 +1,35 @@
+# Agent Note: Keep Loader mutations non-transactional
+
+Status: implemented
+
+English | [中文](2026-09-09-nontransactional-loader.zh.md)
+
+## Problem
+
+Transactional config reload preserves an old plugin generation after a failed edit, but requires Loader to own candidate imports, lifecycle settlement, rollback, option identity, and Include serialization. These changes make the vendored implementation substantially different from its pinned sources. Application startup and profile patch watching also depend on that settlement implicitly.
+
+## Decision
+
+Revert the five commits in [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932), resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins.
+
+Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree.
+
+Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch.
+
+Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure.
+
+Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:^` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary.
+
+## Alternatives considered
+
+**Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback.
+
+**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger.
+
+**Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover.
+
+## Consequences
+
+A plugin activation failure can leave the new options and a failed fiber in place. Callers that require active plugins must audit after settlement; awaiting `Loader.create()` alone does not establish activation. Automatic plugin rollback requires a separate future decision with evidence that its recovery benefit warrants the additional lifecycle implementation.
+
+[Live-patch tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md) retain controlled event delivery and native watcher coverage, while asserting failure reporting without rollback. The [terminal-release policy](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) remains applicable to fatal errors and partial boot teardown. Web preset composition and a real CLI webhook-created model Session provide the application-level verification beyond hand-mounted plugins.

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 保持 Loader 更改非事务化
+
+Status: implemented
+
+[English](2026-09-09-nontransactional-loader.md) | 中文
+
+## 问题
+
+事务化配置重载会在编辑失败后保留旧插件代次,但要求 Loader 负责候选导入、生命周期结算、回滚、选项对象身份和 Include 串行化。这些更改使 vendor 实现与固定来源产生显著差异。应用启动和 profile patch 监视也隐式依赖该结算行为。
+
+## 决策
+
+撤销 [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932) 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。
+
+Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。
+
+应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。
+
+Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。
+
+保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:^` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。
+
+## 考虑过的替代方案
+
+**保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。
+
+**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。
+
+**将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。
+
+## 后果
+
+插件激活失败可能保留新选项和失败的 fiber。要求插件处于激活状态的调用者必须在结算后检查;仅等待 `Loader.create()` 不能证明激活。自动插件回滚需要后续独立决策,并证明其恢复收益值得额外的生命周期实现。
+
+[实时 patch 测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md) 保留受控事件投递和原生监视覆盖,同时断言不回滚时的失败报告。[终端释放策略](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) 仍适用于致命错误和部分启动拆卸。Web preset 组合与真实 CLI webhook 创建的模型 Session 提供手动挂载插件之外的应用级验证。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.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/testing/2026-09-09-user-patch-hmr-test-delivery.md
-2026-09-09-user-patch-hmr-test-delivery.md: 427cf938d38eaf5c351fc0334bfd7df766d5663c
-2026-09-09-user-patch-hmr-test-delivery.zh.md: c2a3a14c9f322e748dbfd131a98138b13c5d47a6
+2026-09-09-user-patch-hmr-test-delivery.md: fadd191650411f07c3b8b1f35c94e73869fbc8e2
+2026-09-09-user-patch-hmr-test-delivery.zh.md: b108cf25766ff108d87c429e9f208c2d4cf7afd2

+ 7 - 7
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md

@@ -1,4 +1,4 @@
-# Agent Note: User-patch transactions control filesystem event delivery
+# Agent Note: User-patch tests control filesystem event delivery
 
 Status: implemented
 
@@ -6,22 +6,22 @@ English | [中文](2026-09-09-user-patch-hmr-test-delivery.zh.md)
 
 ## Problem
 
-The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the transaction assertions from exercising the parser, activation, and rollback behavior they own.
+The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the refresh assertions from exercising the parser, activation, and recovery behavior they own.
 
 ## Decision
 
-The [user-patch transaction test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. HMR registration, refresh serialization, Include recomposition, plugin activation, failure broadcasting, rollback, and recovery remain real. The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
+The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
 
-The separate [HMR config tests](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The transaction test does not establish operating-system delivery guarantees.
+The separate [watcher tests](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
 
 ## Alternatives considered
 
-**Native notifications for every transaction assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
+**Native notifications for every refresh assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
 
 **Polling and fixed settling delays.** Rejected because neither acknowledges delivery of the next edit. Chokidar readiness does not expose completion of Node's asynchronous initial polling baseline; a local polling reproduction still misses changes. Increasing the test deadline cannot recover an event that was never emitted.
 
-**Mock HMR registration or Include.** Rejected because the test must retain transactional recomposition and last-good-state assertions after activation and parse failures.
+**Mock HMR registration or Include.** Rejected because the test must retain real recomposition, report activation failures, and preserve the running configuration after parse failures.
 
 ## Consequences
 
-The transaction sequence retains every semantic assertion and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.
+The refresh sequence retains its semantic assertions and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.

+ 7 - 7
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 用户 patch 事务控制文件系统事件投递
+# Agent Note: 用户 patch 测试控制文件系统事件投递
 
 Status: implemented
 
@@ -6,22 +6,22 @@ Status: implemented
 
 ## 问题
 
-[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止事务断言执行其负责验证的解析、激活与回滚行为。
+[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止刷新断言执行其负责验证的解析、激活与回滚行为。
 
 ## 决策
 
-[用户 patch 事务测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。HMR 注册、刷新串行化、Include 重组、插件激活、失败广播、回滚与恢复仍使用真实实现。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
+[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
 
-独立的 [HMR 配置测试](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。事务测试不验证操作系统的投递保证。
+独立的 [监视器测试](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
 
 ## 考虑过的替代方案
 
-**每个事务断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
+**每个刷新断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
 
 **轮询与固定等待。** 不采用,因为两者都不能确认下一次修改已经投递。Chokidar 就绪状态不暴露 Node 异步初始轮询基线的完成时刻;本地轮询复现仍会遗漏修改。延长测试期限无法恢复从未发出的事件。
 
-**Mock HMR 注册或 Include。** 不采用,因为测试必须保留事务重组,以及激活和解析失败后的最后有效状态断言
+**Mock HMR 注册或 Include。** 不采用,因为测试必须保留真实重组、报告激活失败,并在解析失败后保留运行中的配置
 
 ## 影响
 
-事务序列保留所有语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。
+刷新序列保留其语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。

+ 1 - 0
apps/cli/src/profile-boot.ts

@@ -368,6 +368,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
           await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
         }
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
+        await ctx.loader.await()
       }
       await watchUserPatches(ctx, {
         binName: NAME,

+ 10 - 11
apps/cli/tests/built-bin.e2e.ts

@@ -28,6 +28,7 @@ const SPAWN_TIMEOUT_MS = 60_000
 const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
 const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
 const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
+const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
 
 async function runBuiltBin(
   args: readonly string[] = [],
@@ -400,7 +401,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 3 + 30_000)
 
-  it('reports SDK startup failure when stdin reaches EOF first', async () => {
+  it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
     const patch = join(home, 'broken-sdk.cordis.yml')
     writeFileSync(patch, [
@@ -415,9 +416,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         DSH_TELEMETRY_DISABLED: '1',
         DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
       }, home)
-      expect(result.code).toBe(1)
+      expect(result.code).toBe(0)
       expect(result.stdout).toBe('')
-      expect(result.stderr).toContain('plugin tree failed to load')
+      expect(result.stderr).toContain('warning: 1 entry did not activate')
       expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
     } finally {
       rmSync(home, { recursive: true, force: true })
@@ -756,20 +757,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
-  it('reports a patch-overlay boot failure without hanging', async () => {
-    // The HMR main watcher's initial scan once refreshed the include
-    // mid-initial-apply, deadlocking the failing apply's rollback against the
-    // refresh drain: dsh exited 13 with no diagnostic instead of settling
-    // ([vendor/README.md](../../../vendor/README.md)).
+  it('keeps serving when an optional patch-overlay plugin fails', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
     try {
-      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
+      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
         DSH_HOME: home,
+        DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
         DEEPSEEK_API_KEY: 'keyless-invalid-config',
         DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: `--import=${webReadyExitHook}`,
       })
-      expect(result.code).toBe(1)
-      expect(result.stdout).toBe('')
+      expect(result.code, result.stderr).toBe(0)
+      expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
       expect(result.stderr).toContain('llm-pi-ai')
     } finally {
       rmSync(home, { recursive: true, force: true })

+ 1 - 1
apps/cli/tests/fixtures/invalid-provider.cordis.yml

@@ -1,4 +1,4 @@
-# Invalid `--patch` overlay used to prove boot failures settle and exit.
+# Invalid optional provider override used to prove best-effort startup continues.
 
 - id: llm-pi-ai
   config:

+ 4 - 2
apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt

@@ -1,3 +1,5 @@
-headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure
-Error: startup activation snapshot failure
+dsh: warning: 1 entry did not activate
+activation-error (./activation-error.mjs): Error: startup activation snapshot failure
     at activation-error-fixture
+dsh: reasoning:
+Inspecting the task before the tool call.

+ 1 - 7
apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml

@@ -1,10 +1,4 @@
-# Activation-failure patch over the shipped headless profile.
-- id: headless-startup
-  disabled: true
-
-- id: headless-runner
-  disabled: true
-
+# Unrelated activation failure over the shipped headless profile.
 - insert:
     - id: activation-error
       name: ./activation-error.mjs

+ 16 - 8
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -271,19 +271,27 @@ describe('headless stream-json snapshots', () => {
     await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('prints the original Loader activation error through the assembled one-shot app', async () => {
+  it('warns about an unrelated activation error and completes the headless task', async () => {
     const result = await runLoaderSmoke({
-      label: 'headless startup activation error snapshot',
+      label: 'headless best-effort startup snapshot',
       tempDirPrefix: 'headless-snapshot-startup-error-',
-      binScript,
-      libBinScript: binScript,
+      binScript: dshBinScript,
       configPath: startupFailureConfigPath,
-      binArgs: [startupFailureConfigPath, 'unreachable task'],
+      binArgs: [
+        '--profile', 'headless',
+        '--patch', headlessOverlayPath,
+        '--patch', startupFailureConfigPath,
+        'Complete the task despite the unrelated startup failure.',
+      ],
       tsconfigPath,
-      expectedExitCode: 1,
+      env: {
+        DSH_PERMISSION_MODE: 'danger-full-access',
+        DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+      },
     })
-    expect(result.stdout).toBe('')
-    await expect(result.stderr.replace(startupFailurePluginUrl, './activation-error.mjs'))
+    expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
+    await expect(result.stderr.replaceAll(startupFailurePluginUrl, './activation-error.mjs'))
       .toMatchFileSnapshot(startupFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 

+ 12 - 4
apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts

@@ -6,24 +6,32 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l
 
 const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url)
 const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot))
+const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url))
 const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url))
 
-it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => {
+it('warns about a repeated MCP discovery cursor and completes the headless task', async () => {
   const { stdout, stderr } = await runLoaderSmoke({
     label: 'MCP discovery pagination cycle',
     tempDirPrefix: 'dsh-mcp-pagination-',
     binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)),
     libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)),
     configPath,
-    binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'],
+    binArgs: [
+      '--profile', 'headless',
+      '--patch', headlessOverlayPath,
+      '--patch', configPath,
+      'Complete the task without the failed MCP server.',
+    ],
     tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)),
-    expectedExitCode: 1,
     env: {
       DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)),
+      DSH_PERMISSION_MODE: 'danger-full-access',
       DSH_TELEMETRY_DISABLED: '1',
     },
   })
-  expect(stdout).toBe('')
+  expect(stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
+  expect(stderr).toContain('dsh: warning: 1 entry did not activate')
+  expect(stderr).toContain('mcp-pagination-cycle (@deepseek-ai/dsh-mcp-client)')
   expect(stderr).toContain('initial connection or tool synchronization failed')
   const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):'))
   await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath)

+ 8 - 4
apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts

@@ -32,6 +32,10 @@ const binScript = fileURLToPath(new URL('../../../../../../packages/test-support
 const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url))
 // The resumed-agent fixture in the shared config resumes exactly this id.
 const sessionId = SessionId('workspace-context-resume')
+const fixtureEnv = {
+  DSH_SNAPSHOT_FILE: replayFixture,
+  DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID: 'resumed-agent',
+}
 
 /** Persist one session with the given header version and events, returning its log path. */
 async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> {
@@ -84,7 +88,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Continue the migrated session.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       prepare: async (runCwd) => {
         sourcePath = await seedSession(join(runCwd, '.sessions'), runCwd, 0, closedTurn())
         source = await readFile(sourcePath)
@@ -124,7 +128,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Try to resume.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       expectedExitCode: 1,
       prepare: async (runCwd) => {
         sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn())
@@ -151,7 +155,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Try to resume.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       expectedExitCode: 1,
       prepare: async (runCwd) => {
         sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none')
@@ -197,7 +201,7 @@ describe('session format guard through the assembled app', () => {
       configPath,
       binArgs: [configPath, 'Try to resume.'],
       tsconfigPath,
-      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      env: fixtureEnv,
       expectedExitCode: 1,
       prepare: async (runCwd) => {
         sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [

+ 2 - 1
apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts

@@ -355,7 +355,8 @@ describe('Python SDK dsh profile keyless smoke', () => {
       expect(exitCode, stderr).toBe(1)
       expect(stdout).toBe('')
       expect(stderr).toContain('plugin tree failed to load')
-      expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)')
+      expect(stderr).toContain('required startup failure')
+      expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError')
       expect(stderr).toContain('sometimes')
     } finally {
       await rm(root, { recursive: true, force: true })

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

@@ -0,0 +1,350 @@
+/** Built Web-profile acceptance for best-effort initial plugin activation. */
+
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { Readable } from 'node:stream'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
+const frontendIndex = join(repoRoot, 'apps/web/dist/index.html')
+const builtArtifactsExist = existsSync(dshBin) && existsSync(frontendIndex)
+
+interface Fixture {
+  root: string
+  home: string
+  patch: string
+  events: string
+  stop: string
+}
+
+function createFixture(): Fixture {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-web-best-effort-'))
+  const home = join(root, 'home')
+  const events = join(root, 'events.log')
+  const stop = join(root, 'stop')
+  mkdirSync(home)
+  writeFileSync(events, '')
+  writeFileSync(join(root, 'good.mjs'), [
+    "import { appendFileSync, existsSync } from 'node:fs'",
+    'export function apply(ctx, config) {',
+    "  appendFileSync(config.events, 'good apply\\n')",
+    '  let stopping = false',
+    '  const watcher = setInterval(() => {',
+    '    if (stopping || !existsSync(config.stop)) return',
+    '    stopping = true',
+    "    process.emit('SIGTERM')",
+    '  }, 20)',
+    '  ctx.effect(() => () => {',
+    '    clearInterval(watcher)',
+    "    appendFileSync(config.events, 'good dispose\\n')",
+    '  })',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'sync-failure.mjs'), 'export function apply() { throw new Error("web sync apply failure") }\n')
+  writeFileSync(join(root, 'async-failure.mjs'), [
+    'export async function apply() {',
+    '  await Promise.resolve()',
+    '  throw new Error("web async apply failure")',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'pending.mjs'), [
+    "export const inject = ['webProbeMissingService']",
+    'export function apply() {}',
+    '',
+  ].join('\n'))
+  const patch = join(root, 'failures.patch.yml')
+  writeFileSync(patch, [
+    '- id: tool-todo',
+    '  disabled: false',
+    '  config: {}',
+    '- insert:',
+    '    - id: web-probe-good',
+    `      name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
+    '      config:',
+    `        events: ${JSON.stringify(events)}`,
+    `        stop: ${JSON.stringify(stop)}`,
+    '    - id: web-probe-import-failure',
+    `      name: ${pathToFileURL(join(root, 'missing.mjs')).href}`,
+    '    - id: web-probe-sync-failure',
+    `      name: ${pathToFileURL(join(root, 'sync-failure.mjs')).href}`,
+    '    - id: web-probe-async-failure',
+    `      name: ${pathToFileURL(join(root, 'async-failure.mjs')).href}`,
+    '    - id: web-probe-pending',
+    `      name: ${pathToFileURL(join(root, 'pending.mjs')).href}`,
+    '    - id: web-probe-disabled-failure',
+    `      name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
+    '      disabled: !!js "JSON.parse(\'invalid\')"',
+    '',
+  ].join('\n'))
+  return { root, home, patch, events, stop }
+}
+
+async function waitForStartup(
+  stdout: Readable | null,
+  stderr: Readable | null,
+  completion: PromiseLike<{ exitCode?: number }>,
+): Promise<{ url: string; stderr: string }> {
+  if (stdout === null || stderr === null) throw new Error('Web child pipes are unavailable')
+  stdout.setEncoding('utf8')
+  stderr.setEncoding('utf8')
+  let stdoutText = ''
+  let stderrText = ''
+  let url: string | undefined
+  const ready = Promise.withResolvers<{ url: string; stderr: string }>()
+  let settled = false
+  const finish = (): void => {
+    if (settled || url === undefined) return
+    if (!stderrText.includes('dsh: warning: 6 entries did not activate')) return
+    if (!stderrText.includes('web async apply failure')) return
+    if (!stderrText.includes('webProbeMissingService')) return
+    settled = true
+    clearTimeout(timer)
+    ready.resolve({ url, stderr: stderrText })
+  }
+  stdout.on('data', (chunk: string) => {
+    stdoutText += chunk
+    url ??= /dsh web: (http:\/\/[^\s]+)/u.exec(stdoutText)?.[1]
+    finish()
+  })
+  stderr.on('data', (chunk: string) => {
+    stderrText += chunk
+    finish()
+  })
+  const timer = setTimeout(() => {
+    if (settled) return
+    settled = true
+    ready.reject(new Error(`Web profile did not report ready and failed entries\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
+  }, 60_000)
+  void completion.then((result) => {
+    if (settled) return
+    settled = true
+    clearTimeout(timer)
+    ready.reject(new Error(`Web profile exited before readiness (code ${String(result.exitCode)})\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
+  })
+  return ready.promise
+}
+
+describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () => {
+  it('serves the full Web app while unrelated entries fail to start', async () => {
+    const fixture = createFixture()
+    const child = execa(process.execPath, [
+      dshBin,
+      '--profile', 'web',
+      '--patch', fixture.patch,
+      '--no-open',
+      '--port', '0',
+    ], {
+      cwd: fixture.root,
+      env: {
+        ...process.env,
+        DEEPSEEK_API_KEY: 'keyless-web-best-effort-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',
+    })
+
+    let result: Awaited<typeof child>
+    let events = ''
+    try {
+      const startup = await waitForStartup(child.stdout, child.stderr, child)
+      const auth = await fetch(startup.url, { redirect: 'manual' })
+      const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
+      if (cookie === undefined) throw new Error('Web authentication response did not set a cookie')
+      const page = await fetch(new URL('/', startup.url), { headers: { cookie } })
+      const html = await page.text()
+      expect(html).toContain('<div id="root"></div>')
+      expect(html).toContain('__DSH_BOOT__')
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n')
+      expect(startup.stderr).toContain('web-probe-import-failure')
+      expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo')
+      expect(startup.stderr).toContain('web sync apply failure')
+      expect(startup.stderr).toContain('web async apply failure')
+      expect(startup.stderr).toContain('pending (waiting for service: webProbeMissingService)')
+      expect(startup.stderr).toContain('web-probe-disabled-failure')
+      expect(startup.stderr).toContain('disabled expression failed: SyntaxError')
+    } finally {
+      writeFileSync(fixture.stop, 'stop')
+      result = await child
+      events = readFileSync(fixture.events, 'utf8')
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+
+    expect(result.signal).toBeUndefined()
+    expect({
+      exitCode: result.exitCode,
+      timedOut: result.timedOut,
+      events,
+    }).toMatchInlineSnapshot(`
+      {
+        "events": "good apply
+      good dispose
+      ",
+        "exitCode": 0,
+        "timedOut": false,
+      }
+    `)
+  })
+
+  it.each([
+    ['modules', 'missing dependency'],
+    ['connection', 'missing dependency'],
+    ['modules', 'disabled expression'],
+    ['connection', 'disabled expression'],
+  ])('fails the full Web profile on required %s %s failure', async (id, failure) => {
+    const fixture = createFixture()
+    const patch = failure === 'disabled expression'
+      ? 'disabled: !!js "JSON.parse(\'invalid\')"'
+      : 'inject: [webProbeMissingRequiredService]'
+    const diagnostic = failure === 'disabled expression'
+      ? 'disabled expression failed: SyntaxError'
+      : 'pending (waiting for service: webProbeMissingRequiredService)'
+    writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n  ${patch}\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-required-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.stdout).not.toContain('dsh web: http://')
+      expect(result.stderr).toContain('required startup failure')
+      expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`)
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
+    } finally {
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+  })
+
+  it('fails the full Web profile when its required HTTP server cannot bind', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
+    const home = join(root, 'home')
+    mkdirSync(home)
+    const blocker = createServer()
+    await new Promise<void>((resolve, reject) => {
+      const fail = (error: Error): void => { reject(error) }
+      blocker.once('error', fail)
+      blocker.listen(0, '127.0.0.1', () => {
+        blocker.off('error', fail)
+        resolve()
+      })
+    })
+    const address = blocker.address()
+    if (address === null || typeof address === 'string') {
+      throw new Error('port blocker did not bind a TCP address')
+    }
+
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--no-open',
+        '--port', String(address.port),
+      ], {
+        cwd: root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-required-bind-no-call',
+          DSH_AGENTS_HOME: join(root, '.agents'),
+          DSH_HOME: 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.stdout).not.toContain('dsh web: http://')
+      expect(result.stderr).toContain('required startup failure')
+      expect(result.stderr).toContain('EADDRINUSE')
+    } finally {
+      await new Promise<void>((resolve, reject) => {
+        blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })
+      })
+      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
apps/web/tests/scaffold.ts

@@ -57,7 +57,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import {
-  assertEntriesLoaded,
+  auditStartupEntries,
   composeEntries,
   healProfilesModuleFallback,
   loadOverlayPatches,
@@ -711,7 +711,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       config: { path: pathToFileURL(rootConfig).href, patches },
     })
     await ctx.loader.await()
-    assertEntriesLoaded(ctx, 'web e2e scaffold')
+    await auditStartupEntries(ctx, 'web e2e scaffold')
     if (options.welcomeNoticePending !== true) {
       await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
         op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,

+ 2 - 2
docs/cordis-api/fiber.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 docs/cordis-api/fiber.md
-fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a
-fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b
+fiber.md: 3044301effde267bf580c3a5138481b10513f755
+fiber.zh.md: bee2f523f1fa2a2add2afc3a3fc322903f1ef6f0

+ 3 - 3
docs/cordis-api/fiber.md

@@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config.
  *
  * @param config — the new raw config; validated before anything restarts.
  * @param noSave — hint for persistence hooks not to write the change back.
- * @returns the update waterfall result; the default restart returns a promise.
- * @throws when validation, an update listener, or the restarted plugin fails.
+ * @returns nothing; the restart runs behind the `internal/update` waterfall.
+ * @throws {ValidationError} when the new config fails validation.
  */
 update(config: any, noSave = false)
 ```
@@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o
 - `config` — the new raw config; validated before anything restarts.
 - `noSave` — hint for persistence hooks not to write the change back.
 
-**Returns** the update waterfall result; the default restart returns a promise.
+**Returns** nothing; the restart runs behind the `internal/update` waterfall.
 
 [Source](../../vendor/cordis/src/fiber.ts#L736)
 

+ 3 - 3
docs/cordis-api/fiber.zh.md

@@ -258,8 +258,8 @@ dispose 此插件,并立即使用其当前配置重新加载。
  *
  * @param config — the new raw config; validated before anything restarts.
  * @param noSave — hint for persistence hooks not to write the change back.
- * @returns the update waterfall result; the default restart returns a promise.
- * @throws when validation, an update listener, or the restarted plugin fails.
+ * @returns nothing; the restart runs behind the `internal/update` waterfall.
+ * @throws {ValidationError} when the new config fails validation.
  */
 update(config: any, noSave = false)
 ```
@@ -271,7 +271,7 @@ update(config: any, noSave = false)
 - `config`:新的原始配置;在任何内容重新启动前进行校验。
 - `noSave`:提示持久化钩子不要写回此变更。
 
-**返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise
+**返回**无返回值;重启由 `internal/update` waterfall 执行
 
 [源码](../../vendor/cordis/src/fiber.ts#L736)
 

+ 2 - 2
docs/event-producer-consumer.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 docs/event-producer-consumer.md
-event-producer-consumer.md: 31bcff64bdbd4caa24124f196beef86233f22538
-event-producer-consumer.zh.md: 239149009d0ade0fa2869989e32d2c41fad46c83
+event-producer-consumer.md: 5f06361a1ad423c7c62cee6089298325c60ef96a
+event-producer-consumer.zh.md: 6691e24c1ec3fae9f369f6362861cf9bf1e9fa37

+ 1 - 0
docs/event-producer-consumer.md

@@ -84,5 +84,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
+| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 1 - 0
docs/event-producer-consumer.zh.md

@@ -86,5 +86,6 @@
 | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
+| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 2 - 2
docs/rescope.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 docs/rescope.md
-rescope.md: 676dee2507a974beb20a4c0e8059b6a74aa9c0c9
-rescope.zh.md: 1683111354b1ff7c8771a87b7b03bcbeb0ac0761
+rescope.md: 80a0b1dc67c8b3585f2c18cad3afff9dde5859f9
+rescope.zh.md: aaf65024520308c5d0013d85a128a42839a84573

+ 1 - 1
docs/rescope.md

@@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@
 ## What the rename does not touch
 
 - **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version.
-- **Dependency ranges.** A dependency entry changes its key, never its range: `"cordis": "^4.0.0-rc.7"` becomes `"@deepseek-ai/cordis": "^4.0.0-rc.7"`. `linkWorkspacePackages` resolves those preserved ranges to the pinned workspaces.
+- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use `workspace:^` for repository-owned runtime dependencies, so pnpm resolves the pinned local packages and substitutes release ranges when publishing.
 - **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name.
 - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`.
 - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`.

+ 1 - 1
docs/rescope.zh.md

@@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R
 ## 改名不碰什么
 
 - **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。
-- **依赖 range。** 依赖条目只换键、不换范围:`"cordis": "^4.0.0-rc.7"` 变成 `"@deepseek-ai/cordis": "^4.0.0-rc.7"`;`linkWorkspacePackages` 靠这些保留下来的范围把它们解析到固定的 workspace
+- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单对仓库内的运行时依赖使用 `workspace:^`,因此 pnpm 会解析到固定的本地包,并在发布时替换为版本范围
 - **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。
 - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。
 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。

+ 0 - 1
package.json

@@ -134,7 +134,6 @@
     "verify-client-packages": "tsx scripts/verify-client-packages.ts",
     "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
     "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts",
-    "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
     "rescope-vendor": "tsx scripts/rescope-vendor.ts",
     "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check",

+ 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: a525440f20879314bd8d12b647830f2c541f59b3
-README.zh.md: 90aa0beda52c2968c93eab13bae11640e81e0664
+README.md: 88679e9d4bc53f36d49e8c5b19dcd724ebf573e8
+README.zh.md: 3336ac4e0c56c92e88525af80e6a88cd44b0c081

+ 19 - 6
packages/boot/app-boot/README.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-With that entry point, success looks like a running app with every plugin active; failure is never silent — one labelled line names the failing plugin and the stage, and the process exits nonzero. The app context is torn down before the error is reported, so nothing keeps running half-started.
+With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
 
 <a id="profiles"></a>
 ### Profiles
@@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home:
 - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
 - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
 
-Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
+Profiles with `patchReload: live` watch both user patch files. Parse failures preserve the running configuration; plugin activation failures are reported and can leave a partially applied tree. A later valid edit can recover it. Loader changes are not rolled back. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
@@ -64,7 +64,19 @@ Before you boot, you can print the exact configuration the app will mount: the d
 
 ### What you see when startup fails
 
-Startup failure is a single labelled line plus a nonzero exit — never a silent hang or a raw stack dump. The message names the failing plugin; a plugin that threw keeps its original error, and an entry that never started is reported with the services it was waiting for.
+After the Loader settles, app-boot classifies each enabled entry by stable id. Optional failures produce one warning and leave active siblings running. Required failures produce the same entry detail, then dispose the application and reject startup.
+
+| Failure pattern | Entry result | Startup action |
+|---|---|---|
+| 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 |
+| An entry's `disabled: !!js` expression throws | Entry cannot determine its disabled state; report the evaluation error | 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()` 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 |
+
+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.
 
@@ -86,9 +98,10 @@ This section explains how the outcomes above are realized and points at the code
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **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.
-- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
+- **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. Cyclic causes terminate diagnostic traversal without replacing the original cause.
 
 ### Helper behavior
 
@@ -118,7 +131,7 @@ Read these pages when the package-level contract is not enough. They move from t
 - [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`).
 - [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior.
 - [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design.
-- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of transaction behavior and native filesystem delivery.
+- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of live-patch behavior and native filesystem delivery.
 
 -----
 

+ 19 - 6
packages/boot/app-boot/README.zh.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-有了这个入口,成功就是每个插件都已激活的运行中应用;失败绝不会悄无声息——一行带标签的信息点名失败的插件与阶段,进程以非零码退出。错误上报前会先拆卸应用上下文,因此不会留下半启动的残留
+有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`
 
 <a id="profiles"></a>
 ### Profile
@@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
-带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
+带 `patchReload: live` 的 profile 会监视两份用户 patch 文件。解析失败会保留运行中的配置;插件激活失败会被报告,并可能留下部分应用的配置树。后续有效编辑可以恢复它。Loader 更改不会回滚。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
@@ -64,7 +64,19 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 ### 启动失败时你会看到什么
 
-启动失败是一行带标签的信息加非零退出码——绝不是静默卡死或原始堆栈转储。信息会点名失败的插件;抛错的插件保留原始错误,从未启动的条目会连同它等待的服务一起报告。
+Loader 结算后,app-boot 按稳定 id 对每个已启用 entry 分类。Optional failure 输出一次警告,并让 active sibling 继续运行。Required failure 输出相同的 entry 详情,然后拆卸应用并拒绝启动。
+
+| 失败模式 | Entry 结果 | 启动措施 |
+|---|---|---|
+| 根 YAML 无法读取或解析,或不是 entry list | Bootstrap Include 失败 | 拒绝并拆卸;不接受部分应用 |
+| Plugin module 无法 import | Entry 没有 fiber | Optional 时警告;required 时拒绝并拆卸 |
+| Entry 的 `disabled: !!js` 表达式抛出异常 | Entry 无法确定禁用状态;报告求值错误 | Optional 时警告;required 时拒绝并拆卸 |
+| Config expression 求值或 plugin config schema 在 activation 时失败 | Fiber 为 `FAILED`,保留校验错误 | Optional 时警告;required 时拒绝并拆卸 |
+| 同步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
+| 异步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
+| 必需的 injected service 始终未出现 | Fiber 保持 `PENDING`,并指出缺失 service | Optional 时警告;required 时拒绝并拆卸 |
+
+App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。无关的未处理 rejection 仍然致命。之后的 config HMR 会报告失败,但不会再次应用 required 启动策略,也不会恢复旧 plugin config;有效修改可以恢复失败的 entry。
 
 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。
 
@@ -86,9 +98,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **两个 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 仍然致命
-- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链
+- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功
+- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因
 
 ### Helper 行为
 
@@ -118,7 +131,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。
 - [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。
 - [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。
-- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——事务行为与原生文件系统投递的验证归属。
+- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——实时 patch 行为与原生文件系统投递的验证归属。
 
 -----
 

+ 2 - 2
packages/boot/app-boot/package.json

@@ -29,6 +29,7 @@
   "dependencies": {
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
     "@deepseek-ai/dsh-package-manifest": "workspace:^",
+    "chokidar": "4.0.3",
     "js-yaml": "^4.2.0",
     "resolve.exports": "^2.0.3"
   },
@@ -57,7 +58,6 @@
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@types/js-yaml": "^4.0.9",
-    "@deepseek-ai/cordis": "workspace:^",
-    "chokidar": "4.0.3"
+    "@deepseek-ai/cordis": "workspace:^"
   }
 }

+ 137 - 63
packages/boot/app-boot/src/index.ts

@@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
 import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
+import { watchConfig } from './watch-config.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 declare module '@deepseek-ai/cordis' {
@@ -241,7 +242,7 @@ export interface UserPatchWatchOptions {
 }
 
 /**
- * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
+ * Watch the user patch layer and reapply it to the boot Include without rollback.
  * @param ctx - settled app context containing the root Include and an active HMR service.
  * @param options - diagnostic, file, and patch-composition inputs.
  * @returns an asynchronous disposer after the exact-path watcher is ready.
@@ -256,7 +257,7 @@ export async function watchUserPatches(
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
-  const register = hmr.registerConfig(filename, async () => {
+  const register = watchConfig(ctx, filename, hmr.config, async () => {
     // Re-read the include's non-patch options per refresh so a writer that
     // updates another option between refreshes is not silently reverted.
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
@@ -268,6 +269,10 @@ export async function watchUserPatches(
         patches,
       },
     })
+    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) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
   })
   try {
     return await register
@@ -511,7 +516,7 @@ function groupedDump(
  * names; relative names continue to resolve beside the configuration file.
  * @returns the created root Include entry, or `undefined` when a surface
  * disposed the whole tree (taking the Loader service with it) while the
- * transactional create was still settling entry lifecycle.
+ * entry creation was in flight.
  */
 export async function mountRootInclude(
   ctx: Context,
@@ -610,7 +615,7 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
 /**
  * Install before boot to turn a late unhandled plugin-init rejection into one
  * labelled stderr diagnostic and `exit(1)`. A rejection already included by
- * {@link assertEntriesActivated} is ignored during its process checkpoint;
+ * {@link auditStartupEntries} is ignored during its process checkpoint;
  * every other rejection remains fatal. Stdout remains untouched for ACP; the
  * returned function removes the handler.
  *
@@ -678,21 +683,6 @@ export function installFailLoud(
   return uninstall
 }
 
-/**
- * After the tree settles, reject entries with no fiber and name every plugin
- * whose module failed to resolve. Disabled entries are the only valid
- * fiber-less state.
- * @param ctx - the settled context whose loader entries to audit.
- * @param binName - the diagnostic prefix on the thrown error.
- */
-export function assertEntriesLoaded(ctx: Context, binName: string): void {
-  const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
-  if (failed.length > 0) {
-    const names = failed.map(entry => entry.options.name).join(', ')
-    throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
-  }
-}
-
 /**
  * Value mirrors used because Cordis's const enum has no runtime object to import.
  * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
@@ -702,30 +692,70 @@ const FIBER_PENDING = 0 as FiberState.PENDING
 const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const FIBER_FAILED = 3 as FiberState.FAILED
 
-/** Render a thrown plugin value without discarding an Error's original stack. */
+/**
+ * Entry ids whose presence defines a usable DSH application.
+ *
+ * The list is global rather than profile metadata. Missing or disabled ids do
+ * not affect startup; an enabled listed entry must activate. The list covers
+ * shared Agent execution, application endpoints, and Web bootstrap/transport.
+ */
+const requiredStartupEntryIds = new Set<string>([
+  'agent-loop',
+  'webserver',
+  'modules',
+  'connection',
+  'headless-runner',
+  'acp',
+  'sdk-jsonrpc-server',
+])
+
+/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
 function formatActivationError(error: unknown): string {
-  return error instanceof Error ? error.stack ?? error.message : String(error)
+  const details: string[] = []
+  const seen = new Set<Error>()
+  function visit(value: unknown): void {
+    if (!(value instanceof Error)) {
+      details.push(String(value))
+      return
+    }
+    if (seen.has(value)) return
+    seen.add(value)
+    details.push(value.stack ?? value.message)
+    if (value.cause !== undefined) visit(value.cause)
+    if (value instanceof AggregateError) value.errors.forEach(visit)
+  }
+  visit(error)
+  return details.join('\n')
+}
+
+interface InactiveEntry {
+  /** 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
 }
 
 /**
- * Reject a settled Loader tree when an enabled entry failed or remains inactive.
- * Plugin failures include the original thrown stack; pending entries name their
- * unresolved services because no plugin error exists for that state. Active
- * entries require no further wait; only failed fibers are awaited to recover
- * their private rejection reason.
- * @param ctx - the settled context whose Loader entries to audit.
- * @param binName - the diagnostic prefix on the thrown error.
- * @returns nothing when every enabled entry is active.
- * @throws after one process rejection checkpoint when an entry failed to
- * import, rejected during activation, or did not become active.
+ * Collect Loader activation failures and disabled-expression errors. Failed
+ * fibers are awaited to recover their recorded rejection reason and coalesce
+ * duplicate Loader notifications through the next process rejection checkpoint.
  */
-export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
-  assertEntriesLoaded(ctx, binName)
-  const failures: string[] = []
+async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
+  const failures: InactiveEntry[] = []
   const rejectionReasons: unknown[] = []
   for (const entry of ctx.loader.entries()) {
+    const subject = `${entry.options.id} (${entry.options.name})`
+    try {
+      if (entry.disabled) continue
+    } catch (error) {
+      failures.push({ entry, diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}` })
+      continue
+    }
     const fiber = entry.fiber
-    if (fiber === undefined || entry.disabled) continue
+    if (fiber === undefined) {
+      failures.push({ entry, diagnostic: `${subject}: failed to import` })
+      continue
+    }
     const state = fiber.state
     if (state === FIBER_ACTIVE) continue
     if (state === FIBER_FAILED) {
@@ -733,24 +763,65 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
         await fiber.await()
       } catch (error) {
         rejectionReasons.push(error)
-        failures.push(`${entry.options.name}: ${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)
-      const subject = missing.length === 1 ? 'service' : 'services'
-      failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
+      failures.push({
+        entry,
+        diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
+      })
     } else {
-      failures.push(`${entry.options.name}: fiber state ${String(state)}`)
+      failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` })
     }
   }
-  if (failures.length > 0) {
-    if (rejectionReasons.length > 0) {
-      await observeLoaderRejectionCheckpoint(rejectionReasons)
-    }
-    const noun = failures.length === 1 ? 'entry' : 'entries'
-    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
+  if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
+  return failures
+}
+
+/** Render an inactive-entry diagnostic with a count and severity label. */
+function activationDiagnostic(
+  binName: string,
+  severity: 'warning' | 'required startup failure',
+  failures: readonly InactiveEntry[],
+): string {
+  const noun = failures.length === 1 ? 'entry' : 'entries'
+  const prefix = binName === '' ? '' : `${binName}: `
+  return `${prefix}${severity}: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => failure.diagnostic).join('\n')}\n`
+}
+
+/**
+ * Apply DSH startup policy to a settled Loader tree.
+ *
+ * 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.
+ * A throwing disabled expression is an entry failure, not a disabled entry.
+ * 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 optional warnings if required startup checks pass.
+ * @throws when the bootstrap Include or a required entry is inactive or its disabled expression throws.
+ */
+export async function auditStartupEntries(
+  ctx: Context,
+  binName: string,
+  warn: (line: string) => void = line => void process.stderr.write(line),
+): Promise<void> {
+  const failures = await inactiveEntries(ctx)
+  const required: InactiveEntry[] = []
+  const optional: InactiveEntry[] = []
+  for (const failure of failures) {
+    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))
+  if (required.length > 0) {
+    throw new Error(activationDiagnostic('', 'required startup failure', required).trimEnd())
   }
 }
 
@@ -762,12 +833,11 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * is statically imported and mounted as the `cordis:include` builtin, loading
  * through the ambient module pipeline (vite/tsx/plain ESM). The package build
  * embeds Include while leaving Loader external, so the built include tree and
- * host share one Loader peer. Loader
- * settlement rejects startup failures, which `boot` wraps after disposing the
- * partial context; a missing fiber or never-activating entry is rejected by
- * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
- * init rejection with its original stack; later unhandled rejections remain
- * covered by {@link installFailLoud}. Built bins need the Loader's native
+ * host share one Loader peer. Loader settlement drains entry work without
+ * rejecting the whole tree. The final {@link auditStartupEntries} call rejects
+ * failures in the global required list and warns about other failed, missing,
+ * and pending entries while successful siblings remain active. Later unhandled
+ * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
  * helper for bare plugin specifiers; relative specifiers do not.
  * @param binName - the diagnostic prefix for load-failure errors.
  * @param absoluteConfigPath - the config to include; must already be absolute
@@ -778,11 +848,12 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * @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
- * mounted, `plugin tree failed to load` afterwards.
+ * mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
+ * diagnostic traversal without replacing the original cause.
  */
 export async function boot(
   binName: string,
@@ -798,6 +869,11 @@ export async function boot(
   try {
     ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
     ctx.provide('dshHomePath', dshHomePath)
+    // Fiber.update() discards the restart promise. Observe it before the
+    // waterfall returns; activation audits still report the failed fiber.
+    ctx.on('internal/update', (_config, _noSave, next: () => unknown) => {
+      void Promise.resolve(next()).catch((error: unknown) => { ctx.logger.error(error) })
+    }, { global: true, prepend: true })
     await ctx.plugin(Loader)
     await prepare?.(ctx)
     stage = 'plugin tree failed to load'
@@ -806,12 +882,10 @@ export async function boot(
     // in flight, before the last entry settles. The Loader service goes with
     // it, and the activation audit describes a live tree — reading `ctx.loader`
     // past this point would throw a TypeError over an app that exited exactly
-    // as asked. Transactional group updates settle
-    // lifecycle inside the mount, so the teardown can land before it returns;
-    // re-check after every await.
+    // as asked. Re-check after settlement before auditing the tree.
     await ctx.get('loader')?.await()
     if (ctx.get('loader') === undefined) return ctx
-    await assertEntriesActivated(ctx, binName)
+    await auditStartupEntries(ctx, binName)
     return ctx
   } catch (cause) {
     // Root-fiber disposal contains cleanup failures per observer (Cordis
@@ -819,13 +893,13 @@ export async function boot(
     // result, so this await cannot reject and replace `cause`.
     await ctx.fiber.dispose()
     const detail = cause instanceof Error ? cause.message : String(cause)
-    // The transactional Loader wraps a failing entry apply in one message per
-    // tree layer; every layer's message is folded into `detail` above, and the
-    // deepest cause is the plugin's own thrown error, whose stack names the
-    // real failure site — append it so the startup diagnostic preserves the
-    // original activation error instead of only the wrap chain.
+    // A wrapper can carry an activation error whose original stack names the failed plugin.
     let deepest: unknown = cause
-    while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
+    const seen = new Set<Error>()
+    while (deepest instanceof Error && !seen.has(deepest) && deepest.cause !== undefined) {
+      seen.add(deepest)
+      deepest = deepest.cause
+    }
     const stack = deepest instanceof AggregateError
       ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
       : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''

+ 89 - 0
packages/boot/app-boot/src/watch-config.ts

@@ -0,0 +1,89 @@
+/** Exact-path watching for live profile patch files outside Cordis module roots. */
+import { dirname, relative, resolve } from 'node:path'
+import { realpath, stat } from 'node:fs/promises'
+import { watch, type ChokidarOptions } from 'chokidar'
+import type { Context } from '@deepseek-ai/cordis'
+
+const registrations = new WeakMap<Context, Set<string>>()
+
+async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
+  let root = dirname(filename)
+  let depth = 0
+  while (true) {
+    try {
+      if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
+      const canonicalRoot = await realpath(root)
+      return { filename: resolve(canonicalRoot, relative(root, filename)), root: canonicalRoot, depth }
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      const parent = dirname(root)
+      if (parent === root) throw error
+      root = parent
+      depth += 1
+    }
+  }
+}
+
+/**
+ * Watch one patch path, including missing parents, and serialize refresh callbacks.
+ * @param ctx Context that owns watcher disposal and receives refresh failures.
+ * @param filename Absolute patch-file path.
+ * @param options Deployment watcher options inherited from the HMR configuration.
+ * @param refresh Callback for additions, changes, and removals.
+ * @returns A disposer that closes the watcher and drains its current refresh.
+ * @throws When path resolution, watcher startup, or effect registration fails.
+ */
+export async function watchConfig(
+  ctx: Context, filename: string, options: ChokidarOptions, refresh: () => Promise<void> | void,
+): Promise<() => Promise<void>> {
+  const target = await findWatchRoot(filename)
+  const paths = registrations.get(ctx) ?? new Set<string>()
+  registrations.set(ctx, paths)
+  if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`)
+  const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options
+  const watcher = watch(target.root, {
+    ...watchOptions, depth: target.depth, ignoreInitial: false,
+  })
+  paths.add(target.filename)
+  const state = { dirty: false }
+  let running: Promise<void> | undefined
+  const onChange = (path: string) => {
+    const observed = resolve(path)
+    if (observed !== filename && observed !== target.filename) return
+    state.dirty = true
+    if (running) return
+    running = (async () => {
+      while (state.dirty) {
+        state.dirty = false
+        try {
+          await refresh()
+        } catch (reason) {
+          const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
+          ctx.logger.warn('config reload at %C failed', filename)
+          ctx.logger.warn(error)
+        }
+      }
+    })().finally(() => { running = undefined })
+  }
+  watcher.on('add', onChange)
+  watcher.on('change', onChange)
+  watcher.on('unlink', onChange)
+  const ready = Promise.withResolvers<void>()
+  let pending = true
+  watcher.once('ready', () => { pending = false; ready.resolve() })
+  watcher.on('error', (error) => {
+    if (pending) { pending = false; ready.reject(error) } else { ctx.logger.warn(error) }
+  })
+  const dispose = async () => {
+    await watcher.close()
+    paths.delete(target.filename)
+    await running
+  }
+  try {
+    await ready.promise
+    return ctx.effect(() => dispose, 'app-boot.watchConfig()')
+  } catch (error) {
+    await dispose()
+    throw error
+  }
+}

+ 307 - 133
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -6,7 +6,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
-  addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
+  addHarnessSourceSection, auditStartupEntries, boot,
   FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
   installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
 } from '../src/index.ts'
@@ -419,10 +419,11 @@ describe('installFailLoud', () => {
     const proc = fakeProc()
     installFailLoud(NAME, proc)
     const error = new Error('assembled activation failure')
-    const audit = assertEntriesActivated({
+    const warn = vi.fn()
+    const audit = auditStartupEntries({
       loader: {
         entries: () => ['broken-a', 'broken-b'].map(name => ({
-          options: { name },
+          options: { id: name, name },
           fiber: {
             state: 3,
             inject: {},
@@ -431,13 +432,14 @@ describe('installFailLoud', () => {
           },
         })),
       },
-    } as unknown as Context, NAME)
+    } as unknown as Context, NAME, warn)
     await Promise.resolve()
     await Promise.resolve()
     proc.handlers[0]!(error)
     expect(proc.written).toEqual([])
     expect(proc.exits).toEqual([])
-    await expect(audit).rejects.toThrow('assembled activation failure')
+    await audit
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure'))
     proc.handlers[0]!(error)
     expect(proc.exits).toEqual([1])
   })
@@ -501,36 +503,30 @@ describe('installFailLoud', () => {
   })
 })
 
-describe('assertEntriesLoaded', () => {
-  const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
-    ({ loader: { entries: () => entries } }) as unknown as Context
-
-  it('passes when every enabled entry has a fiber', () => {
-    expect(() => { assertEntriesLoaded(ctxWith([
-      { fiber: {}, options: { name: 'a' } },
-      { disabled: true, options: { name: 'off' } },
-    ]), NAME) }).not.toThrow()
-  })
-
-  it('throws naming every enabled fiber-less entry', () => {
-    expect(() => { assertEntriesLoaded(ctxWith([
-      { fiber: {}, options: { name: 'ok' } },
-      { options: { name: 'broken-a' } },
-      { options: { name: 'broken-b' } },
-    ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
-  })
-})
-
-describe('assertEntriesActivated', () => {
-  interface FakeFiber {
-    state: number
-    inject: Record<string, unknown>
-    ctx: { get(name: string): unknown }
-    await(): Promise<unknown>
+describe('auditStartupEntries', () => {
+  const requiredIds = [
+    'agent-loop',
+    'webserver',
+    'modules',
+    'connection',
+    'headless-runner',
+    'acp',
+    'sdk-jsonrpc-server',
+  ]
+
+  interface FakeEntry {
+    fiber?: {
+      state: number
+      inject: Record<string, unknown>
+      ctx: { get(name: string): unknown }
+      await(): Promise<unknown>
+    }
+    disabled?: boolean
+    options: { id: string; name: string }
   }
 
-  const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
-    loader: { entries: () => entries },
+  const ctxWith = (entries: FakeEntry[]): Context => ({
+    loader: { entries: () => entries.values() },
   }) as unknown as Context
 
   const fiber = (
@@ -538,77 +534,163 @@ describe('assertEntriesActivated', () => {
     error?: unknown,
     inject: Record<string, unknown> = {},
     services: string[] = [],
-  ): FakeFiber => ({
+  ): NonNullable<FakeEntry['fiber']> => ({
     state,
     inject,
     ctx: { get: name => services.includes(name) ? {} : undefined },
     await: error === undefined ? async () => undefined : async () => { throw error },
   })
 
-  it('passes active entries and ignores disabled entries', async () => {
-    let awaitCalls = 0
-    const active = fiber(2)
-    active.await = async () => {
-      awaitCalls++
-      return undefined
+  it('ignores active, disabled, and absent required entries', async () => {
+    const warn = vi.fn()
+    await expect(auditStartupEntries(ctxWith([]), NAME, warn)).resolves.toBeUndefined()
+    for (const disabled of [false, true]) {
+      await expect(auditStartupEntries(ctxWith(requiredIds.map(id => ({
+        fiber: disabled ? fiber(3, new Error('disabled failure')) : fiber(2),
+        disabled,
+        options: { id, name: './required.mjs' },
+      }))), NAME, warn)).resolves.toBeUndefined()
     }
-    const disabled = fiber(3, new Error('disabled failure'))
-    disabled.await = async () => {
-      awaitCalls++
-      throw new Error('disabled failure')
+    expect(warn).not.toHaveBeenCalled()
+  })
+
+  it('warns once for optional import, apply, and dependency failures', async () => {
+    const warn = vi.fn()
+    const original = new Error('todo apply failure')
+    await auditStartupEntries(ctxWith([
+      { options: { id: 'missing-tool', name: './missing.mjs' } },
+      { fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
+      {
+        fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']),
+        options: { id: 'waiting-tool', name: './waiting.mjs' },
+      },
+    ]), NAME, warn)
+    expect(warn).toHaveBeenCalledOnce()
+    expect(warn).toHaveBeenCalledWith([
+      `${NAME}: warning: 3 entries did not activate`,
+      'missing-tool (./missing.mjs): failed to import',
+      `tool-todo (@deepseek-ai/dsh-tool-todo): ${original.stack!}`,
+      'waiting-tool (./waiting.mjs): pending (waiting for service: missing)',
+      '',
+    ].join('\n'))
+  })
+
+  it.each([
+    { id: 'tool-todo', required: false },
+    { id: 'webserver', required: true },
+  ])('reports a throwing disabled expression on $id (required: $required)', async ({ id, required }) => {
+    const error = new Error('disabled evaluation failed')
+    const warn = vi.fn()
+    const result = auditStartupEntries(ctxWith([{
+      options: { id, name: './plugin.mjs' },
+      get disabled(): boolean { throw error },
+    }]), NAME, warn)
+    const detail = `${id} (./plugin.mjs): disabled expression failed: ${error.stack!}`
+    if (required) {
+      await expect(result).rejects.toThrow(`required startup failure: 1 entry did not activate\n${detail}`)
+      expect(warn).not.toHaveBeenCalled()
+    } else {
+      await expect(result).resolves.toBeUndefined()
+      expect(warn).toHaveBeenCalledExactlyOnceWith(`${NAME}: warning: 1 entry did not activate\n${detail}\n`)
     }
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: active, options: { name: 'active' } },
-      { fiber: disabled, disabled: true, options: { name: 'disabled' } },
-    ]), NAME)).resolves.toBeUndefined()
-    expect(awaitCalls).toBe(0)
   })
 
-  it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
-    const original = new Error('actual plugin failure')
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
-    ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
+  it('preserves nested activation causes and aggregate member failures', async () => {
+    const warn = vi.fn()
+    const original = new Error('tool discovery failed')
+    const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', {
+      cause: new Error('server rejected discovery'),
+    })
+    const wrapper = new Error('plugin activation failed', { cause: aggregate })
+    await auditStartupEntries(ctxWith([
+      { fiber: fiber(3, wrapper), options: { id: 'wrapped-plugin', name: './wrapped.mjs' } },
+    ]), NAME, warn)
+    expect(warn).toHaveBeenCalledWith([
+      `${NAME}: warning: 1 entry did not activate`,
+      `wrapped-plugin (./wrapped.mjs): ${wrapper.stack!}`,
+      aggregate.stack!,
+      (aggregate.cause as Error).stack!,
+      original.stack!,
+      'transport closed',
+      '',
+    ].join('\n'))
   })
 
-  it('formats stackless and non-Error activation failures', async () => {
+  it('describes nested, stackless, non-error, pending, and unexpected failures', async () => {
+    const warn = vi.fn()
+    const circular = new Error('circular failure')
+    ;(circular as { cause?: unknown }).cause = circular
     const stackless = new Error('stackless failure')
     delete (stackless as { stack?: string }).stack
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(3, stackless), options: { name: 'stackless' } },
-      { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
-    ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
-  })
-
-  it('reports unresolved services for pending entries', async () => {
-    let awaitCalls = 0
-    const expected = [
-      `${NAME}: 3 entries did not activate`,
-      'waiting: pending (waiting for services: missingA, missingB)',
-      'single-wait: pending (waiting for service: missing)',
-      'unknown-wait: pending (waiting for services: unknown)',
-    ].join('\n')
-    const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
-    const singleWait = fiber(0, undefined, { missing: {} })
-    const unknownWait = fiber(0)
-    for (const item of [waiting, singleWait, unknownWait]) {
-      item.await = async () => {
-        awaitCalls++
-        return undefined
-      }
-    }
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: waiting, options: { name: 'waiting' } },
-      { fiber: singleWait, options: { name: 'single-wait' } },
-      { fiber: unknownWait, options: { name: 'unknown-wait' } },
-    ]), NAME)).rejects.toThrow(expected)
-    expect(awaitCalls).toBe(0)
+    const deepestWithStack = new Error('deep failure with stack')
+    const deepestWithoutStack = new Error('deep failure without stack')
+    delete (deepestWithoutStack as { stack?: string }).stack
+    const wrappedStack = new Error('wrapped stack', { cause: deepestWithStack })
+    const wrappedStackless = new Error('wrapped stackless', { cause: deepestWithoutStack })
+    const wrappedValue = new Error('wrapped value', { cause: 'plain cause' })
+
+    await auditStartupEntries(ctxWith([
+      { fiber: fiber(3, circular), options: { id: 'circular', name: './circular.mjs' } },
+      { fiber: fiber(3, stackless), options: { id: 'stackless', name: './stackless.mjs' } },
+      {
+        fiber: fiber(3, wrappedStack),
+        options: { id: 'deep-stack', name: './deep-stack.mjs' },
+      },
+      {
+        fiber: fiber(3, wrappedStackless),
+        options: { id: 'deep-stackless', name: './deep-stackless.mjs' },
+      },
+      {
+        fiber: fiber(3, wrappedValue),
+        options: { id: 'plain-cause', name: './plain-cause.mjs' },
+      },
+      { fiber: fiber(3, 42), options: { id: 'number-error', name: './number-error.mjs' } },
+      {
+        fiber: fiber(0, undefined, { first: {}, second: {} }),
+        options: { id: 'multiple-dependencies', name: './multiple-dependencies.mjs' },
+      },
+      {
+        fiber: fiber(0),
+        options: { id: 'unknown-dependency', name: './unknown-dependency.mjs' },
+      },
+      { fiber: fiber(1), options: { id: 'unexpected-state', name: './unexpected-state.mjs' } },
+    ]), NAME, warn)
+
+    expect(warn).toHaveBeenCalledOnce()
+    const diagnostic = String(warn.mock.calls[0]![0])
+    expect(diagnostic).toContain(`${NAME}: warning: 9 entries did not activate`)
+    expect(diagnostic).toContain(`circular (./circular.mjs): ${circular.stack!}`)
+    expect(diagnostic).toContain('stackless (./stackless.mjs): stackless failure')
+    expect(diagnostic).toContain(`deep-stack (./deep-stack.mjs): ${wrappedStack.stack!}\n${deepestWithStack.stack!}`)
+    expect(diagnostic).toContain(`deep-stackless (./deep-stackless.mjs): ${wrappedStackless.stack!}\ndeep failure without stack`)
+    expect(diagnostic).toContain(`plain-cause (./plain-cause.mjs): ${wrappedValue.stack!}\nplain cause`)
+    expect(diagnostic).toContain('number-error (./number-error.mjs): 42')
+    expect(diagnostic).toContain('multiple-dependencies (./multiple-dependencies.mjs): pending (waiting for services: first, second)')
+    expect(diagnostic).toContain('unknown-dependency (./unknown-dependency.mjs): pending (waiting for services: unknown)')
+    expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1')
   })
 
-  it('retains the numeric diagnostic for a settled unexpected state', async () => {
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(4), options: { name: 'disposed' } },
-    ]), NAME)).rejects.toThrow('disposed: fiber state 4')
+  it.each(requiredIds)('rejects required %s failures after warning about optional failures', async (id) => {
+    const warn = vi.fn()
+    const requiredError = new Error('address already in use')
+    const optionalError = new Error('todo unavailable')
+    await expect(auditStartupEntries(ctxWith([
+      { fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } },
+      { fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
+    ]), NAME, warn)).rejects.toThrow([
+      'required startup failure: 1 entry did not activate',
+      `${id} (./required.mjs): ${requiredError.stack!}`,
+    ].join('\n'))
+    expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
+  })
+
+  it('rejects a required entry pending on an injected service', async () => {
+    await expect(auditStartupEntries(ctxWith([{
+      fiber: fiber(0, undefined, { headlessStartup: {} }),
+      options: { id: 'headless-runner', name: '@deepseek-ai/dsh-headless' },
+    }]), NAME, vi.fn())).rejects.toThrow(
+      'headless-runner (@deepseek-ai/dsh-headless): pending (waiting for service: headlessStartup)',
+    )
   })
 })
 
@@ -729,22 +811,6 @@ describe('boot', () => {
     }
   })
 
-  it('disposes partial host setup and labels non-Error preparation failures', async () => {
-    const dir = tmp()
-    const failure = 42
-    let disposed = false
-    const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
-      ctx.effect(() => () => { disposed = true })
-      throw failure
-    })
-
-    await expect(task).rejects.toMatchObject({
-      message: `${NAME}: host preparation failed: ${failure}`,
-      cause: failure,
-    })
-    expect(disposed).toBe(true)
-  })
-
   it('exposes dshHomePath to Loader config expressions', async () => {
     const dir = tmp()
     const dshHome = join(dir, 'home')
@@ -802,48 +868,140 @@ describe('boot', () => {
     expect(ctx.get('loader')).toBeUndefined()
   })
 
-  it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
+  it('returns when disposal completes before root entry creation returns', async () => {
     const dir = tmp()
-    writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n  name: ./missing.mjs\n')
-    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
-      `${NAME}: plugin tree failed to load: failed to apply loader entry`,
-    )
+    writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+    const ctx = await boot(NAME, join(dir, 'cordis.yml'), [], (ctx) => {
+      const create = ctx.loader.create.bind(ctx.loader)
+      vi.spyOn(ctx.loader, 'create').mockImplementation(async (...args) => {
+        const id = await create(...args)
+        await ctx.fiber.dispose()
+        return id
+      })
+    })
+    expect(ctx.get('loader')).toBeUndefined()
   })
 
-  it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
+  it('keeps successful entries and warns about optional import, config, disabled, sync apply, async apply, and dependency failures', async () => {
     const dir = tmp()
     const configPath = join(dir, 'cordis.yml')
     const config = [
+      '- id: good',
+      '  name: ./good.mjs',
+      '- id: import-failure',
+      '  name: ./missing.mjs',
       '- id: invalid-config',
       '  name: ./noop.mjs',
       '  config:',
       '    value: !!js "JSON.parse(\'invalid\')"',
+      '- id: disabled-failure',
+      '  name: ./noop.mjs',
+      '  disabled: !!js "JSON.parse(\'invalid\')"',
+      '- id: sync-failure',
+      '  name: ./sync-failure.mjs',
+      '- id: async-failure',
+      '  name: ./async-failure.mjs',
+      '- id: waiting',
+      '  name: ./waiting.mjs',
       '',
     ].join('\n')
+    writeFileSync(join(dir, 'good.mjs'), 'export function apply(ctx) { ctx.provide("goodStarted", true) }\n')
     writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
+    writeFileSync(join(dir, 'sync-failure.mjs'), 'export function apply() { throw new Error("sync apply failure") }\n')
+    writeFileSync(join(dir, 'async-failure.mjs'), 'export async function apply() { throw new Error("async apply failure") }\n')
+    writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
     writeFileSync(configPath, config)
 
-    await expect(boot(NAME, configPath)).rejects.toThrow(
-      'failed to apply loader entry invalid-config (./noop.mjs)',
-    )
-    expect(readFileSync(configPath, 'utf8')).toBe(config)
+    const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    let ctx: Context | undefined
+    try {
+      ctx = await boot(NAME, configPath)
+      expect(ctx.get('goodStarted')).toBe(true)
+      const entries = [...ctx.loader.entries()]
+      expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)
+      expect(entries.find(entry => entry.options.id === 'import-failure')?.fiber).toBeUndefined()
+      expect(entries.find(entry => entry.options.id === 'disabled-failure')?.fiber).toBeUndefined()
+      for (const id of ['invalid-config', 'sync-failure', 'async-failure']) {
+        expect(entries.find(entry => entry.options.id === id)?.fiber?.state).toBe(3)
+      }
+      expect(entries.find(entry => entry.options.id === 'waiting')?.fiber?.state).toBe(0)
+      const warning = write.mock.calls.map(call => String(call[0])).join('')
+      expect(warning).toContain(`${NAME}: warning: 6 entries did not activate`)
+      expect(warning).toContain('import-failure (./missing.mjs): failed to import')
+      expect(warning).toContain('disabled-failure (./noop.mjs): disabled expression failed: SyntaxError')
+      expect(warning).toContain('SyntaxError: Unexpected token')
+      expect(warning).toContain('sync apply failure')
+      expect(warning).toContain('async apply failure')
+      expect(warning).toContain('waiting for service: neverProvided')
+      expect(readFileSync(configPath, 'utf8')).toBe(config)
+    } finally {
+      write.mockRestore()
+      await ctx?.fiber.dispose()
+    }
+  })
+
+  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('appends the deepest cause with its original stack to the load failure', async () => {
+  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'],
+    ['disabled expression', 'export function apply() {}\n', '  disabled: !!js "JSON.parse(\'invalid\')"\n', 'required startup failure: 1 entry did not activate\nwebserver (./required.mjs): disabled expression failed: 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()
-    writeFileSync(join(dir, 'failing.mjs'), [
-      'export function apply() {',
-      "  const failure = new Error('pinned activation failure')",
-      "  failure.stack = 'Error: pinned activation failure\\n    at failing-fixture'",
-      '  throw failure',
+    let disposed = false
+    writeFileSync(join(dir, 'good.mjs'), [
+      'export function apply(ctx) {',
+      '  globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = false',
+      '  ctx.effect(() => () => { globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = true })',
       '}',
       '',
     ].join('\n'))
-    writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n  name: ./failing.mjs\n')
+    writeFileSync(join(dir, 'required-failure.mjs'), 'export function apply() { throw new Error("required apply failure") }\n')
+    writeFileSync(join(dir, 'cordis.yml'), [
+      '- id: good',
+      '  name: ./good.mjs',
+      '- id: webserver',
+      '  name: ./required-failure.mjs',
+      '',
+    ].join('\n'))
+
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
-      String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
-      String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
-    ].join('')))
+      'plugin tree failed to load: required startup failure: 1 entry did not activate',
+      String.raw`webserver \(\.\/required-failure\.mjs\):`,
+      'required apply failure',
+    ].join(String.raw`[\s\S]*`)))
+    disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false
+    delete (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__
+    expect(disposed).toBe(true)
   })
 
   it('falls back to the deepest cause message when its stack was erased', async () => {
@@ -857,6 +1015,31 @@ describe('boot', () => {
     )
   })
 
+  it('reports a non-Error rejection from host preparation', async () => {
+    await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], () => {
+      throw 'host refused'
+    })).rejects.toThrow(`${NAME}: host preparation failed: host refused`)
+  })
+
+  it.each([false, true])('rejects and disposes when an error cause is cyclic (indirect: %s)', async (indirect) => {
+    const failure = new Error('cyclic setup failure')
+    const next = indirect ? new Error('nested failure', { cause: failure }) : failure
+    let reads = 0
+    Object.defineProperty(failure, 'cause', {
+      get() {
+        // Bound a regressed synchronous traversal so it cannot hang the test worker.
+        if (++reads > 10) throw new Error('cause traversal did not terminate')
+        return next
+      },
+    })
+    const dispose = vi.fn()
+    await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], (ctx) => {
+      ctx.effect(() => dispose)
+      throw failure
+    })).rejects.toThrow(`${NAME}: host preparation failed: cyclic setup failure`)
+    expect(dispose).toHaveBeenCalledOnce()
+  })
+
   it('expands a stackless aggregate at the deepest activation cause', async () => {
     const dir = tmp()
     const aggregate = new AggregateError([
@@ -879,15 +1062,6 @@ describe('boot', () => {
     }
   })
 
-  it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
-    const dir = tmp()
-    writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
-    writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n  name: ./waiting.mjs\n')
-    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
-      `${NAME}: 1 entry did not activate`,
-      './waiting.mjs: pending (waiting for service: neverProvided)',
-    ].join('\n'))
-  })
 })
 
 describe('addHarnessSourceSection', () => {

+ 93 - 228
packages/boot/app-boot/tests/config-reload.spec.ts

@@ -1,10 +1,6 @@
-/**
- * Transactional config replacement through the booted Include and Loader tree.
- * HMR contains rejected refreshes; direct callers receive the error after the
- * previous generation has been retained or restored.
- */
+/** File reload and overlay behavior through the booted Include tree. */
 
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, describe, expect, it } from 'vitest'
@@ -43,41 +39,40 @@ function entryConfig(ctx: Context, id: string): unknown {
   return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
 }
 
-function entryById(ctx: Context, id: string) {
-  const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
-  if (!entry) throw new Error(`missing loader entry ${id}`)
-  return entry
-}
-
-function plugin(name: string, body = ''): string {
-  return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
-}
-
-async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
-  try {
-    await task
-  } catch (error) {
-    expect(error).toBeInstanceOf(Error)
-    expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
-    return
-  }
-  throw new Error(`expected loader update to fail during ${stage}`)
-}
-
 describe('include refresh with an invalid file', () => {
-  it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
+  it('writes and activates initial entries when an included file is missing', async () => {
+    const { ctx, dir } = await bootTree([
+      '- id: initialized',
+      "  name: 'cordis:include'",
+      '  config:',
+      '    path: ./created.yml',
+      '    initial:',
+      '      - id: noop',
+      '        name: ./noop.mjs',
+      '        config: { value: initial }',
+      '',
+    ].join('\n'))
+    try {
+      expect(entryConfig(ctx, 'noop')).toEqual({ value: 'initial' })
+      expect(readFileSync(join(dir, 'created.yml'), 'utf8')).toContain('id: noop')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
     const { ctx, dir, include } = await bootTree('- id: noop\n  name: ./noop.mjs\n  config:\n    value: 1\n')
     try {
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
 
       writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
-      await expect(include.refresh()).rejects.toThrow('failed to parse config file')
+      await expect(include.refresh()).resolves.toBeUndefined()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
 
       // An empty file parses to `undefined` without a YAML error; it must be
       // treated exactly like a parse failure, not crash the entry walk.
       writeFileSync(join(dir, 'cordis.yml'), '')
-      await expect(include.refresh()).rejects.toThrow('failed to validate config file')
+      await expect(include.refresh()).resolves.toBeUndefined()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
 
       writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n  config:\n    value: 2\n')
@@ -90,201 +85,6 @@ describe('include refresh with an invalid file', () => {
   })
 })
 
-describe('loader entry replacement', () => {
-  it('imports a changed name before replacing the running plugin', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./old.mjs\n', {
-      'old.mjs': plugin('oldPlugin'),
-      'new.mjs': plugin('newPlugin'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      await entry.update({ name: './new.mjs' })
-      expect(entry.options.name).toBe('./new.mjs')
-      expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
-      expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
-      expect(entry.options.disabled).toBeUndefined()
-      await entry.fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('retains the running plugin when the replacement cannot be imported', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./old.mjs\n', {
-      'old.mjs': plugin('oldPlugin'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const fiber = entry.fiber
-      await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
-      expect(entry.options.name).toBe('./old.mjs')
-      expect(entry.fiber === fiber).toBe(true)
-      await fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('restores the previous plugin after replacement application fails', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./old.mjs\n', {
-      'old.mjs': plugin('oldPlugin'),
-      'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const previous = entry.fiber
-      await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
-      expect(entry.options.name).toBe('./old.mjs')
-      expect(entry.fiber === previous).toBe(false)
-      expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
-      expect(entry.options.disabled).toBeUndefined()
-      await entry.fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('restores the previous config when an in-place restart fails', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./configurable.mjs\n  config:\n    fail: false\n', {
-      'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const fiber = entry.fiber
-      await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
-      expect(entry.options.config).toEqual({ fail: false })
-      expect(entry.fiber === fiber).toBe(true)
-      await fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('does not persist a failed direct fiber update', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./configurable.mjs\n  config:\n    fail: false\n', {
-      'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const fiber = entry.fiber
-      if (!fiber) throw new Error('target entry has no fiber')
-      await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
-      expect(entry.options.config).toEqual({ fail: false })
-      expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-})
-
-describe('loader tree replacement', () => {
-  it('rolls back earlier updates and additions when a later entry fails', async () => {
-    const { ctx, dir, include } = await bootTree([
-      '- id: existing',
-      '  name: ./configurable.mjs',
-      '  config:',
-      '    value: old',
-      '',
-    ].join('\n'), {
-      'configurable.mjs': plugin('configurablePlugin'),
-      'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
-    })
-    try {
-      writeFileSync(join(dir, 'cordis.yml'), [
-        '- id: existing',
-        '  name: ./configurable.mjs',
-        '  config:',
-        '    value: candidate',
-        '- id: added',
-        '  name: ./noop.mjs',
-        '- id: bad',
-        '  name: ./bad.mjs',
-        '',
-      ].join('\n'))
-      await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
-      expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
-      expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
-      expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
-
-      writeFileSync(join(dir, 'cordis.yml'), [
-        '- id: existing',
-        '  name: ./configurable.mjs',
-        '  config:',
-        '    value: committed',
-        '- id: added',
-        '  name: ./noop.mjs',
-        '',
-      ].join('\n'))
-      await include.refresh()
-      expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
-      expect(entryById(ctx, 'added').fiber).toBeDefined()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
-    // No manual builtin registration: `boot()` supplies `cordis:group` beside
-    // `cordis:include`, which is what lets a composition give one `isolate`
-    // realm to a provider and its consumers together.
-    const { ctx, dir, include } = await bootTree('- id: noop\n  name: ./noop.mjs\n')
-    try {
-      const config = (disabled: boolean) => [
-        '- id: parent',
-        '  name: cordis:group',
-        '  group: true',
-        `  disabled: ${disabled}`,
-        '  config:',
-        '    - id: child',
-        '      name: ./noop.mjs',
-        '',
-      ].join('\n')
-
-      writeFileSync(join(dir, 'cordis.yml'), config(false))
-      await include.refresh()
-      expect(entryById(ctx, 'child').fiber).toBeDefined()
-
-      writeFileSync(join(dir, 'cordis.yml'), config(true))
-      await include.refresh()
-      expect(entryById(ctx, 'child').fiber).toBeUndefined()
-
-      writeFileSync(join(dir, 'cordis.yml'), config(false))
-      await include.refresh()
-      expect(entryById(ctx, 'child').fiber).toBeDefined()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('restores a programmatic entry move when its update fails', async () => {
-    const { ctx } = await bootTree('- id: noop\n  name: ./noop.mjs\n', {
-      'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
-    })
-    try {
-      const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
-      const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
-      const target = entryById(ctx, targetId)
-      const source = target.parent
-      const sourceIndex = source.data.indexOf(target.options)
-      const destination = entryById(ctx, groupId).subgroup
-      if (!destination) throw new Error('created loader group has no subgroup')
-
-      await expectUpdateFailure(
-        ctx.loader.update(targetId, { config: { fail: true } }, groupId),
-        'apply',
-      )
-
-      expect(target.parent).toBe(source)
-      expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
-      expect(source.data.indexOf(target.options)).toBe(sourceIndex)
-      expect(destination.data).not.toContain(target.options)
-      expect(target.options.config).toEqual({ fail: false })
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-})
-
 describe('include refresh with overlay patches', () => {
   it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
@@ -335,9 +135,9 @@ describe('include refresh with overlay patches', () => {
       await ctx.loader.await()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
 
-      // Omitting the patch list must remove the overlay rather than reuse the
-      // Include's previous config through a default parameter.
-      await entry.update({ config: { path: './base.yml' } })
+      // Removing every patch must revert to the file's own values: patching
+      // may not bake earlier patch results into the cached parse.
+      await entry.update({ config: { path: './base.yml', patches: [] } })
       await ctx.loader.await()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
     } finally {
@@ -395,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

+ 0 - 213
packages/boot/app-boot/tests/hmr-config.spec.ts

@@ -1,213 +0,0 @@
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
-import { realpath } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { Context } from '@deepseek-ai/cordis'
-import Hmr from '@deepseek-ai/cordis-plugin-hmr'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
-import Timer from '@deepseek-ai/cordis-plugin-timer'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-/** Every per-test tree root, removed once the booted watcher has been disposed. */
-const hmrRoots: string[] = []
-
-async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
-  const ctx = new Context()
-  ctx.baseUrl = pathToFileURL(dir).href + '/'
-  await ctx.plugin(Loader)
-  await ctx.plugin(Timer)
-  await ctx.plugin(Hmr, {
-    root,
-    ignored: [],
-    debounce: 0,
-    ...usePolling === undefined ? {} : { usePolling },
-  })
-  return ctx
-}
-
-async function eventually(test: () => boolean, message: string): Promise<void> {
-  const deadline = Date.now() + 10_000
-  while (!test()) {
-    if (Date.now() >= deadline) throw new Error(message)
-    await new Promise(resolve => setTimeout(resolve, 10))
-  }
-}
-
-describe('HMR exact config paths', () => {
-  afterEach(() => {
-    for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
-  })
-
-  it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
-    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
-    const alias = `${target}-alias`
-    const aliasFilename = join(alias, 'module.ts')
-    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
-    writeFileSync(aliasFilename, 'export const generation = 0\n')
-    // This acceptance owns alias-to-cache identity. Other cases below exercise
-    // native events; polling keeps Windows fs.watch queue pressure out of it.
-    const ctx = await bootHmr(alias, ['.'], true)
-    const filename = join(await realpath(target), 'module.ts')
-    const expected = pathToFileURL(filename).href
-    const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
-    const observed: string[] = []
-    ctx.on('hmr/change', (url) => { observed.push(url) })
-    try {
-      const deadline = Date.now() + 20_000
-      for (let generation = 1; !observed.includes(expected); generation += 1) {
-        if (Date.now() >= deadline) {
-          throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
-        }
-        // The watch base, not the writer spelling, is the alias under test.
-        // Grow the file on every write: polling must not depend on timestamp
-        // precision when several generations land inside one filesystem tick.
-        writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
-        // Leave Chokidar's atomic-write window idle so one coalesced change can publish.
-        await new Promise(resolve => setTimeout(resolve, 250))
-      }
-      expect(cacheHas).toHaveBeenCalledWith(expected)
-    } finally {
-      await ctx.fiber.dispose()
-      unlinkSync(alias)
-      rmSync(target, { recursive: true, force: true })
-    }
-  })
-
-  it('collapses filesystem aliases before registering an exact watch', async () => {
-    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
-    const alias = `${target}-alias`
-    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
-    const ctx = await bootHmr(alias)
-    try {
-      await ctx.hmr.registerConfig('plugins.yml', () => {})
-      await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {}))
-        .rejects.toThrow('config path already registered')
-    } finally {
-      await ctx.fiber.dispose()
-      unlinkSync(alias)
-      rmSync(target, { recursive: true, force: true })
-    }
-  })
-
-  it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(dir)
-    const filename = join(dir, 'plugins.yml')
-    const ctx = await bootHmr(dir)
-    const observed: string[] = []
-    try {
-      await ctx.hmr.registerConfig(filename, () => {
-        try {
-          observed.push(readFileSync(filename, 'utf8'))
-        } catch (error) {
-          if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-          observed.push('missing')
-        }
-      })
-
-      writeFileSync(filename, 'one', { flag: 'wx' })
-      await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
-      writeFileSync(filename, 'two')
-      await eventually(() => observed.includes('two'), 'HMR did not observe config change')
-      unlinkSync(filename)
-      await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
-    const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(root)
-    const dir = join(root, 'later')
-    const filename = join(dir, 'plugins.yml')
-    const ctx = await bootHmr(root)
-    const observed: string[] = []
-    try {
-      await ctx.hmr.registerConfig(filename, () => {
-        observed.push(readFileSync(filename, 'utf8'))
-      })
-      mkdirSync(dir)
-      writeFileSync(filename, 'created')
-      await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(dir)
-    const filename = join(dir, 'plugins.yml')
-    writeFileSync(filename, 'one')
-    const ctx = await bootHmr(dir)
-    const started = Promise.withResolvers<undefined>()
-    const release = Promise.withResolvers<undefined>()
-    const observed: string[] = []
-    let active = 0
-    let maxActive = 0
-    try {
-      const dispose = await ctx.hmr.registerConfig(filename, async () => {
-        active += 1
-        maxActive = Math.max(maxActive, active)
-        observed.push(readFileSync(filename, 'utf8'))
-        if (observed.length === 1) {
-          started.resolve(undefined)
-          await release.promise
-        }
-        active -= 1
-      })
-      await started.promise
-      writeFileSync(filename, 'two')
-      // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
-      // that window so this edit is queued before registration disposal.
-      await new Promise(resolve => setTimeout(resolve, 250))
-
-      let disposed = false
-      const disposal = dispose().then(() => { disposed = true })
-      await Promise.resolve()
-      expect(disposed).toBe(false)
-      release.resolve(undefined)
-      await disposal
-      expect(maxActive).toBe(1)
-      expect(observed).toEqual(['one', 'two'])
-    } finally {
-      release.resolve(undefined)
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(dir)
-    const filename = join(dir, 'plugins.yml')
-    const ctx = await bootHmr(dir)
-    const failure = Promise.withResolvers<{ filename: string; error: Error }>()
-    let failureCount = 0
-    try {
-      ctx.on('hmr/config-update-failed', () => {
-        throw new Error('observer failed')
-      })
-      ctx.on('hmr/config-update-failed', (failedFilename, error) => {
-        failureCount += 1
-        failure.resolve({ filename: failedFilename, error })
-      })
-      await ctx.hmr.registerConfig(filename, () => { throw 42 })
-      writeFileSync(filename, 'invalid')
-
-      const observed = await failure.promise
-      expect(observed.filename).toBe(filename)
-      expect(observed.error).toBeInstanceOf(Error)
-      expect(observed.error.message).toBe('42')
-
-      // Let Chokidar's atomic-write window close before requiring a distinct
-      // second notification from the same path.
-      await new Promise(resolve => setTimeout(resolve, 250))
-      writeFileSync(filename, 'invalid again')
-      await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-})

+ 63 - 43
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -1,7 +1,7 @@
 /**
  * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
  * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
- * a real Loader tree, kept live through transactional HMR.
+ * a real Loader tree with live file watching.
  */
 
 import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -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')
 }
 
@@ -270,7 +270,9 @@ describe('Loader config interpolation', () => {
       await provider?.update({ disabled: true })
       await provider?.update({ config: { fail: true } })
       await provider?.update({ disabled: false })
-      await expect(ctx.loader.await()).rejects.toThrow('rejected provider')
+      await ctx.loader.await()
+      const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
+      await expect(reader?.fiber?.await()).rejects.toThrow('rejected provider')
       expect(ctx.get('readerResult')).toBeUndefined()
 
       await provider?.update({ disabled: true })
@@ -342,7 +344,8 @@ describe('Loader entry disabled interpolation', () => {
       const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
       await entry?.update({ disabled: disabledTrue })
       expect(entry?.disabled).toBe(true)
-      expect(entry?.fiber).toBeUndefined()
+      await ctx.loader.await()
+      expect(entry?.fiber?.uid).toBeNull()
       await entry?.update({ disabled: disabledFalse })
       expect(entry?.disabled).toBe(false)
       expect(entry?.fiber).toBeDefined()
@@ -403,17 +406,21 @@ describe('boot with user patches', () => {
     }
   })
 
-  it('watches add, failure, recovery, and removal through transactional HMR', { 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 })
-    // Native notifications belong to hmr-config.spec.ts; this case owns the
-    // real HMR/Include transaction after each delivered filesystem event.
+    // Native notifications belong to watch-config.spec.ts; this case owns
+    // Include recomposition after each explicitly delivered filesystem event.
     const watchers: FSWatcher[] = []
     const previousFactory = configWatch.create
     onTestFinished(() => { configWatch.create = previousFactory })
@@ -423,10 +430,11 @@ describe('boot with user patches', () => {
       queueMicrotask(() => { watcher.emit('ready') })
       return watcher
     }
-    const failures: Array<{ filename: string; error: Error }> = []
-    ctx.on('hmr/config-update-failed', (failedFilename, error) => {
-      failures.push({ filename: failedFilename, error })
+    const failures: Error[] = []
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation((value: unknown) => {
+      if (value instanceof Error) failures.push(value)
     })
+    onTestFinished(() => { warn.mockRestore() })
     const dispose = await watchUserPatches(ctx, {
       binName: NAME,
       filename,
@@ -435,31 +443,40 @@ 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 broadcast')
-      expect(failures[0]).toMatchObject({ filename })
-      expect(failures[0]?.error).toBeInstanceOf(Error)
-      expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
+      await eventually(() => failures.length === 1, 'failed candidate was not reported')
+      expect(failures[0]).toBeInstanceOf(Error)
+      expect(entryConfig(ctx, id)).toMatchObject({ fail: true })
+
+      writeFileSync(filename, `- id: ${id}\n  disabled: !!js "JSON.parse('invalid')"\n`)
+      watcher.emit('change', filename)
+      await eventually(() => failures.length === 2, 'disabled expression failure was not reported')
+      expect(failures[1]?.message).toContain(`${id} (./noop.mjs): disabled expression failed: SyntaxError`)
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
+        .toEqual({ __jsExpr: "JSON.parse('invalid')" })
 
       writeFileSync(filename, 'invalid: [unclosed\n')
       watcher.emit('change', filename)
-      await eventually(() => failures.length === 2, 'parse failure was not broadcast')
-      expect(failures[1]?.error).toBeInstanceOf(Error)
-      expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
+      await eventually(() => failures.length === 3, 'parse failure was not reported')
+      expect(failures[2]).toBeInstanceOf(Error)
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
+        .toEqual({ __jsExpr: "JSON.parse('invalid')" })
 
-      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')
+      await ctx.loader.await()
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.fiber?.state).toBe(2)
 
       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')
-      expect(failures).toHaveLength(2)
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
+      expect(failures).toHaveLength(3)
 
       // Default compose: the user layer IS the whole patch list, so a
       // fresh generation replaces the app-owned layer instead of stacking on it.
@@ -467,9 +484,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()
       }
@@ -494,21 +511,24 @@ describe('boot with user patches', () => {
   })
 
   it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
-    // A surface can dispose the whole tree while registerConfig's effect
-    // registration is still in flight (the HMR effect then fails with
-    // INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher
-    // must not crash the process. The stub makes the race deterministic — the
-    // live-teardown ordering itself is not stageable.
     const dir = tmp()
     const ctx = await boot(NAME, writeTree(dir))
-    try {
-      const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
-      ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
-      const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
-      await expect(dispose()).resolves.toBeUndefined()
-    } finally {
-      await ctx.fiber.dispose()
+    await ctx.plugin(Timer)
+    await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    const release = Promise.withResolvers<undefined>()
+    ctx.effect(() => () => release.promise)
+    let disposal: Promise<void> | undefined
+    onTestFinished(async () => { release.resolve(undefined); await disposal })
+    configWatch.create = (options) => {
+      const watcher = new FSWatcher(options)
+      disposal = ctx.fiber.dispose()
+      queueMicrotask(() => { watcher.emit('ready') })
+      return watcher
     }
+    const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
+    await expect(dispose()).resolves.toBeUndefined()
   })
 
   it('propagates registration failures other than mid-teardown', async () => {
@@ -519,7 +539,7 @@ describe('boot with user patches', () => {
       await ctx.plugin(Timer)
       await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
       const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
-      // Same user-layer path registered twice: HMR refuses; not a teardown race.
+      // Same user-layer path registered twice: the watcher refuses; not a teardown race.
       await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
       await dispose()
     } finally {

+ 286 - 0
packages/boot/app-boot/tests/watch-config.spec.ts

@@ -0,0 +1,286 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { realpath } from 'node:fs/promises'
+import * as fsPromises from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, parse } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import { watchConfig } from '../src/watch-config.ts'
+import Hmr from '@deepseek-ai/cordis-plugin-hmr'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Timer from '@deepseek-ai/cordis-plugin-timer'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { FSWatcher, type ChokidarOptions } from 'chokidar'
+
+const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined }))
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const native = await importOriginal<typeof import('node:fs/promises')>()
+  return { ...native, stat: vi.fn(native.stat) }
+})
+vi.mock('chokidar', async (importOriginal) => {
+  const native = await importOriginal<typeof import('chokidar')>()
+  return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) =>
+    configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) }
+})
+
+/** Every per-test tree root, removed once the booted watcher has been disposed. */
+const hmrRoots: string[] = []
+
+async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(dir).href + '/'
+  await ctx.plugin(Loader)
+  await ctx.plugin(Timer)
+  await ctx.plugin(Hmr, {
+    root,
+    ignored: [],
+    debounce: 0,
+    ...usePolling === undefined ? {} : { usePolling },
+  })
+  return ctx
+}
+
+async function eventually(test: () => boolean, message: string): Promise<void> {
+  const deadline = Date.now() + 10_000
+  while (!test()) {
+    if (Date.now() >= deadline) throw new Error(message)
+    await new Promise(resolve => setTimeout(resolve, 10))
+  }
+}
+
+describe('HMR exact config paths', () => {
+  afterEach(() => {
+    for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+  })
+
+  it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
+    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
+    const alias = `${target}-alias`
+    const aliasFilename = join(alias, 'module.ts')
+    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
+    writeFileSync(aliasFilename, 'export const generation = 0\n')
+    // This acceptance owns alias-to-cache identity. Other cases below exercise
+    // native events; polling keeps Windows fs.watch queue pressure out of it.
+    const ctx = await bootHmr(alias, ['.'], true)
+    const filename = join(await realpath(target), 'module.ts')
+    const expected = pathToFileURL(filename).href
+    const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
+    const observed: string[] = []
+    ctx.on('hmr/change', (url) => { observed.push(url) })
+    try {
+      const deadline = Date.now() + 20_000
+      for (let generation = 1; !observed.includes(expected); generation += 1) {
+        if (Date.now() >= deadline) {
+          throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
+        }
+        // The watch base, not the writer spelling, is the alias under test.
+        // Grow the file on every write: polling must not depend on timestamp
+        // precision when several generations land inside one filesystem tick.
+        writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
+        // Leave Chokidar's atomic-write window idle so one coalesced change can publish.
+        await new Promise(resolve => setTimeout(resolve, 250))
+      }
+      expect(cacheHas).toHaveBeenCalledWith(expected)
+    } finally {
+      await ctx.fiber.dispose()
+      unlinkSync(alias)
+      rmSync(target, { recursive: true, force: true })
+    }
+  })
+
+  it('collapses filesystem aliases before registering an exact watch', async () => {
+    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
+    const alias = `${target}-alias`
+    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
+    const ctx = await bootHmr(alias)
+    try {
+      await watchConfig(ctx, join(alias, 'plugins.yml'), {}, () => {})
+      await expect(watchConfig(ctx, join(await realpath(target), 'plugins.yml'), {}, () => {}))
+        .rejects.toThrow('config path already registered')
+    } finally {
+      await ctx.fiber.dispose()
+      unlinkSync(alias)
+      rmSync(target, { recursive: true, force: true })
+    }
+  })
+
+  it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(dir)
+    const observed: string[] = []
+    try {
+      await watchConfig(ctx, filename, {}, () => {
+        try {
+          observed.push(readFileSync(filename, 'utf8'))
+        } catch (error) {
+          if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+          observed.push('missing')
+        }
+      })
+
+      writeFileSync(filename, 'one', { flag: 'wx' })
+      await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
+      writeFileSync(filename, 'two')
+      await eventually(() => observed.includes('two'), 'HMR did not observe config change')
+      unlinkSync(filename)
+      await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(root)
+    const dir = join(root, 'later')
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(root)
+    const observed: string[] = []
+    try {
+      await watchConfig(ctx, filename, {}, () => {
+        observed.push(readFileSync(filename, 'utf8'))
+      })
+      mkdirSync(dir)
+      writeFileSync(filename, 'created')
+      await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('serializes refreshes and waits for them during disposal', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(dir)
+    onTestFinished(() => ctx.fiber.dispose())
+    const watcher = new FSWatcher()
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    onTestFinished(() => { release.resolve(undefined) })
+    let calls = 0
+    let active = 0
+    let maxActive = 0
+    const dispose = await watchConfig(ctx, filename, {}, async () => {
+      active += 1
+      maxActive = Math.max(maxActive, active)
+      if (++calls === 1) {
+        started.resolve(undefined)
+        await release.promise
+      }
+      active -= 1
+    })
+    watcher.emit('change', join(dir, 'unrelated.yml'))
+    expect(calls).toBe(0)
+    watcher.emit('add', filename)
+    await started.promise
+    watcher.emit('change', filename)
+    watcher.emit('unlink', filename)
+    let disposed = false
+    const disposal = dispose().then(() => { disposed = true })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+    release.resolve(undefined)
+    await disposal
+    expect(maxActive).toBe(1)
+    expect(calls).toBe(2)
+  })
+
+  it('rejects a patch path whose parent is a regular file', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-'))
+    hmrRoots.push(dir)
+    const parent = join(dir, 'file')
+    writeFileSync(parent, '')
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await expect(watchConfig(ctx, join(parent, 'plugins.yml'), {}, () => {}))
+      .rejects.toThrow('config watch parent is not a directory')
+  })
+
+  it('stops searching when the filesystem root cannot be read', async () => {
+    const failure = Object.assign(new Error('filesystem root unavailable'), { code: 'ENOENT' })
+    const read = vi.mocked(fsPromises.stat).mockClear().mockRejectedValueOnce(failure)
+    onTestFinished(() => { read.mockRestore() })
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await expect(watchConfig(ctx, join(parse(tmpdir()).root, 'plugins.yml'), {}, () => {})).rejects.toBe(failure)
+    expect(read).toHaveBeenCalledOnce()
+  })
+
+  it.each(['creation', 'ready'] as const)('releases registration after watcher %s fails', async (phase) => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-watch-failure-'))
+    hmrRoots.push(dir)
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    const filename = join(dir, 'plugins.yml')
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    const failure = new Error('watcher unavailable')
+    const failed = new FSWatcher()
+    const closed = vi.spyOn(failed, 'close')
+    configWatch.create = () => {
+      if (phase === 'creation') throw failure
+      queueMicrotask(() => { failed.emit('error', failure) })
+      return failed
+    }
+    await expect(watchConfig(ctx, filename, {}, () => {})).rejects.toBe(failure)
+    if (phase === 'ready') expect(closed).toHaveBeenCalledOnce()
+    const watcher = new FSWatcher()
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    await watchConfig(ctx, filename, {}, () => {})
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    onTestFinished(() => { warn.mockRestore() })
+    watcher.emit('error', failure)
+    expect(warn).toHaveBeenCalledWith(failure)
+  })
+
+  it('closes a ready watcher when its context has already been disposed', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-disposed-'))
+    hmrRoots.push(dir)
+    const root = new Context()
+    const fiber = root.plugin(() => {})
+    await fiber
+    const ctx = fiber.ctx
+    await fiber.dispose()
+    const watcher = new FSWatcher()
+    const close = vi.spyOn(watcher, 'close')
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    await expect(watchConfig(ctx, join(dir, 'plugins.yml'), {}, () => {}))
+      .rejects.toThrow('cannot create effect on inactive context')
+    expect(close).toHaveBeenCalledOnce()
+  })
+
+  it('logs a normalized refresh failure and continues processing later events', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-failure-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(dir)
+    onTestFinished(() => ctx.fiber.dispose())
+    const watcher = new FSWatcher()
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    onTestFinished(() => { warn.mockRestore() })
+    let calls = 0
+    const recovered = Promise.withResolvers<undefined>()
+    await watchConfig(ctx, filename, {}, () => {
+      if (++calls === 1) throw 42
+      recovered.resolve(undefined)
+    })
+    watcher.emit('change', filename)
+    await expect.poll(() => warn.mock.calls.length).toBe(2)
+    expect(warn.mock.calls[0]).toEqual(['config reload at %C failed', filename])
+    expect(warn.mock.calls[1]?.[0]).toMatchObject({ message: '42' })
+    watcher.emit('change', filename)
+    await recovered.promise
+    expect(calls).toBe(2)
+  })
+})

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: aea694942173a856861d00a69f15b451cc930976
-README.zh.md: 4dab93ff03071bff6783ae89b0397216b86a8c05
+README.md: 29f1737a1a3367c5d1f4e9a37e7031fcd6ae9b9f
+README.zh.md: 55e3cc26d8f6b70d464280d2acb2ec0cf37bc937

+ 1 - 1
packages/bundle/web-app/README.md

@@ -77,7 +77,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve
 
 ### Readiness
 
-The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles and Connection authentication is available — or immediately in a hand-built tree without a Loader. A tree disposed mid-boot announces nothing.
+The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
 
 ### LAN trust sampling
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -77,7 +77,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己
 
 ### 就绪宣告
 
-URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。启动中途被释放的树不会宣告任何内容。
+URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
 
 ### LAN 信任采样
 

+ 6 - 4
packages/bundle/web-app/src/index.ts

@@ -18,7 +18,7 @@ import { networkInterfaces } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
+import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
 import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
@@ -284,15 +284,17 @@ export function apply(ctx: Context, config: Config): void {
       const settled = connectionCtx.get('loader')?.await()
       if (settled === undefined) announceReady()
       else {
-        void settled.then(() => {
+        void settled.then(async () => {
+          await auditStartupEntries(connectionCtx.root, 'dsh web', () => {})
           // The tree can be disposed while the boot was in flight (early
           // SIGTERM); a URL line or browser tab for a dead server would only
           // mislead, and reading torn-down services would turn a clean shutdown
           // into a crash.
           if (connectionCtx.get('webServer') !== undefined
             && connectionCtx.get('connection') !== undefined) announceReady()
-        // Loader reports a failed boot; this row only stays quiet.
-        }, () => {})
+        }).catch(() => {
+          // Boot owns the failure diagnostic; readiness remains unpublished.
+        })
       }
     })
   }

+ 37 - 2
packages/bundle/web-app/tests/web-app.spec.ts

@@ -11,8 +11,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { PassThrough } from 'node:stream'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import * as AppBoot from '@deepseek-ai/dsh-app-boot'
 import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
@@ -100,7 +102,7 @@ function provideConnection(ctx: Context): void {
 
 /** A fake Loader whose settlement the test controls (the URL line waits on it). */
 function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
-  ctx.provide('loader', { await: settle } as never)
+  ctx.provide('loader', { await: settle, entries: () => [] } as never)
 }
 
 interface BashContribution {
@@ -313,6 +315,39 @@ describe('web-app runtime glue', () => {
     await torn.fiber.dispose()
   })
 
+  it.each([
+    { id: 'webserver', announces: false },
+    { id: 'modules', announces: false },
+    { id: 'connection', announces: false },
+    { id: 'optional-tool', announces: true },
+  ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
+    stageDist()
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    ctx.baseUrl = 'file:///'
+    ctx.provide('webServer', fakeHttpServer().server)
+    provideConnection(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.failure = () => { throw new Error('sibling rejected') }
+    await ctx.loader.root.update([{ id, name: 'cordis:failure' }])
+    await ctx.loader.await()
+    await expect(ctx.loader.resolve(id).fiber?.await()).rejects.toThrow('sibling rejected')
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    const openBrowser = vi.fn(async () => {})
+    internals.openBrowser = openBrowser
+    const audit = vi.spyOn(AppBoot, 'auditStartupEntries')
+    apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
+    await vi.waitFor(() => { expect(audit).toHaveBeenCalledOnce() })
+    await Promise.allSettled(audit.mock.results.map(result => result.value as Promise<void>))
+    if (announces) {
+      expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
+      expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
+    } else {
+      expect(log).not.toHaveBeenCalled()
+      expect(openBrowser).not.toHaveBeenCalled()
+    }
+  })
+
   it('fails loud when the prompt section resolves against a portless webserver', async () => {
     stageDist()
     const ctx = new Context()

+ 2 - 2
packages/client/web/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/client/web/README.md
-README.md: 6bc0c4f241d1afb28825cadc19f89b7f54d4b637
-README.zh.md: e2d7ab81e82f1b5b1b93b95e4ed117a80f897a8e
+README.md: 7bdcec9e2a964b746cbd808497f9222e2b4150fa
+README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337

+ 1 - 1
packages/client/web/README.md

@@ -35,7 +35,7 @@ Boot runs in two stages: the module stage adopts the parser-loaded bootstrap bat
 
 ### The boot page
 
-The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import error, or state) instead of a blank page.
+The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import failure, or state) instead of a blank page. The console contains the original import error.
 
 ### The shared module table
 

+ 1 - 1
packages/client/web/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-library"
 
 ### 启动页
 
-启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入错误或状态),而不是白屏
+启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入失败或状态),而不是白屏。控制台包含原始导入错误
 
 ### 共享模块表
 

+ 2 - 2
packages/client/web/src/boot-client.ts

@@ -27,8 +27,8 @@ export interface ClientBootOptions {
 /**
  * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
  * `loader.create({ name })` per manifest row, `loader.await()`, then
- * {@link assertEntriesActive}. A row whose module cannot be imported rejects
- * `loader.create`, so that import error propagates from here as-is.
+ * {@link assertEntriesActive}. A row whose module cannot be imported is marked
+ * failed; the Loader logs its import error and the audit rejects startup.
  * @param options - context, module system, manifest, optional progress sink.
  * @returns resolves after every entry is active; rejects with the audit report otherwise.
  */

+ 9 - 5
packages/client/web/tests/boot-client.client.spec.ts

@@ -4,7 +4,7 @@ import {
   createClientModuleSystem, parseBootManifest,
   type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
 } from '@deepseek-ai/dsh-client-modules/client'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
 import { FIBER_STATE } from '../src/loader-status.ts'
 
@@ -77,17 +77,21 @@ describe('bootClient', () => {
     await ctx.fiber.dispose()
   })
 
-  it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => {
+  it('reports and logs an import failure for a row that is neither seeded nor a graph row', async () => {
     const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
     const manifest = parseBootManifest(graphOf(['ghost']))
     const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
+    onTestFinished(() => { error.mockRestore() })
     const sink = stateSink()
 
     await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
-      /failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/,
+      'web boot: 1 entry did not activate\nghost: import failed (see console for the import error)',
     )
-    expect(sink.states.get('ghost')).toEqual(['loading'])
-    await ctx.fiber.dispose()
+    expect(sink.states.get('ghost')).toEqual(['loading', 'failed'])
+    expect(error).toHaveBeenCalledOnce()
+    expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('client-modules: cannot resolve'))
   })
 })
 

+ 2 - 2
packages/extensions/cordis-client-runner/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/extensions/cordis-client-runner/README.md
-README.md: 260d4703bda2560a4254962d7b868221d0328ec8
-README.zh.md: 9f101f8ddcf4934a9f9ba4cdc4725050d0e8cbfa
+README.md: 153a8bf3b71d2c4c8edf3423dd0b7004083d1ecc
+README.zh.md: 207b3d40c56110d734499bb516e41872c53b262a

+ 1 - 1
packages/extensions/cordis-client-runner/README.md

@@ -51,7 +51,7 @@ This section explains the design behind the browser half; the observable behavio
 
 ### Design philosophy
 
-The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload is entry removal plus factory invalidation plus style removal. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel.
+The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload removes the entry, waits for its fiber's cleanup, then invalidates its factory and removes its styles. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel.
 
 ### Source map
 

+ 1 - 1
packages/extensions/cordis-client-runner/README.zh.md

@@ -51,7 +51,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载 = 移除 entry + 失效 factory + 撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry-error seam,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。
+浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载先移除 entry,等待其 fiber 清理完成,再使 factory 失效并撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry-error seam,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。
 
 ### 源码地图
 

+ 3 - 1
packages/extensions/cordis-client-runner/src/client/runtime.ts

@@ -451,7 +451,9 @@ export class DynamicCordisPackageRunner {
     this.failures.delete(id)
     // Entry removal disposes the fiber (slot entries and facade effects
     // cascade); the factory invalidation makes a later re-load legal.
-    await this.env.loader.remove(entryId)
+    const disposal = this.env.loader.resolve(entryId).fiber?.dispose()
+    this.env.loader.remove(entryId)
+    await disposal
     this.env.modules.invalidate(moduleIdOf(id))
     styles.dispose()
   }

+ 25 - 5
packages/extensions/cordis-client-runner/tests/runner.client.spec.ts

@@ -13,7 +13,7 @@
 
 import { Context } from '@deepseek-ai/cordis'
 import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type {
   CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId,
 } from '@deepseek-ai/dsh-api-remotes/client'
@@ -52,7 +52,7 @@ interface Bench {
   invalidated: string[]
   removed: string[]
   created: string[]
-  invoke: ReturnType<typeof vi.fn>
+  invoke: ReturnType<typeof vi.fn<() => Promise<unknown>>>
   /** Render failures the runner sent upstream, in order. */
   reported: {
     agentId: SessionId
@@ -103,15 +103,15 @@ async function boot(): Promise<Bench> {
       return Promise.resolve(entryId)
     },
     resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
-    remove: async (entryId: string) => {
+    remove: (entryId: string) => {
       removed.push(entryId)
       const entry = fibers.get(entryId)
       fibers.delete(entryId)
-      await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
+      void (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
     },
   } as unknown as Loader
 
-  const invoke = vi.fn(() => Promise.resolve(null))
+  const invoke = vi.fn<() => Promise<unknown>>(() => Promise.resolve(null))
   const reported: Bench['reported'] = []
   // The crash seam is stood in so a test can report an entry failure without a
   // React render, exactly as the renderer's boundary would; registrations still
@@ -298,6 +298,26 @@ describe('failure stages', () => {
 })
 
 describe('retract', () => {
+  it('waits for plugin cleanup before invalidating its module factory', async () => {
+    const bench = await boot()
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    onTestFinished(() => { release.resolve(undefined) })
+    bench.invoke.mockImplementation(() => {
+      started.resolve(undefined)
+      return release.promise
+    })
+    await bench.runner.load(half({ code: 'return { apply: (ctx) => ctx.effect(() => () => host.call("cleanup", null)) }' }))
+    bench.runner.retract(PLUGIN, RUN)
+    await started.promise
+    await bench.settle()
+    expect(bench.removed).toEqual(['entry-1'])
+    expect(bench.invalidated).toEqual(['dyn/dyn-1'])
+    release.resolve(undefined)
+    await bench.settle()
+    expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
+  })
+
   it('unloads at the named revision', async () => {
     const bench = await boot()
     await bench.runner.load(half())

+ 10 - 5
packages/host/directory-picker-auto/src/index.ts

@@ -77,15 +77,20 @@ export async function apply(ctx: Context): Promise<void> {
       for (const id of [...ids].reverse()) {
         // Tree teardown (group.stop) can have removed the entry already;
         // nothing is left to unmount or await then.
-        if (ctx.loader.store[id] === undefined) continue
-        // remove() disposes the entry transactionally, so the chooser's unload
-        // signals completion only after that face quiesced.
-        await ctx.loader.remove(id)
+        const entry = ctx.loader.store[id]
+        if (entry === undefined) continue
+        const disposal = entry.fiber?.dispose()
+        ctx.loader.remove(id)
+        await disposal
       }
     }
     try {
       for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) {
-        ids.push(await ctx.loader.create({ name }))
+        const id = await ctx.loader.create({ name })
+        ids.push(id)
+        const entry = ctx.loader.resolve(id)
+        if (entry.fiber === undefined) throw new Error(`directory-picker-auto: failed to load ${name}`)
+        await entry.fiber.await()
       }
     } catch (cause) {
       // Setup owns the entries it created until it returns the disposer: leaving

+ 20 - 4
packages/host/directory-picker-auto/tests/loader-composition.spec.ts

@@ -12,7 +12,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
@@ -205,7 +205,21 @@ describe('real Loader composition', () => {
     // and the disposer joins the backend's teardown — the service is gone the
     // moment dispose() settles, with no further loader await.
     const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
-    await autoEntry.fiber!.dispose()
+    const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
+    const cleanupStarted = Promise.withResolvers<undefined>()
+    const releaseCleanup = Promise.withResolvers<undefined>()
+    onTestFinished(() => { releaseCleanup.resolve(undefined) })
+    backendEntry.fiber!.ctx.effect(() => async () => {
+      cleanupStarted.resolve(undefined)
+      await releaseCleanup.promise
+    })
+    let disposed = false
+    const disposal = autoEntry.fiber!.dispose().then(() => { disposed = true })
+    await cleanupStarted.promise
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+    releaseCleanup.resolve(undefined)
+    await disposal
     expect(entryNames(ctx)).not.toContain(NATIVE)
     expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
     expect(ctx.get('directoryPicker')).toBeUndefined()
@@ -245,7 +259,9 @@ describe('real Loader composition', () => {
 
   it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => {
     stubAttendedHost()
-    await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/)
+    const { ctx } = await loadComposition('127.0.0.1', { failSurface: true })
+    const chooser = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
+    await expect(chooser.fiber!.await()).rejects.toThrow(/directory-picker-auto: failed to load/)
 
     // Setup owns both entries until it returns its disposer, so a failed surface
     // must take the mounted backend with it: otherwise a retry collides with the
@@ -259,7 +275,7 @@ describe('real Loader composition', () => {
     const { ctx, configPath } = await loadComposition('127.0.0.1')
 
     const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
-    await ctx.loader.remove(backendEntry.id)
+    ctx.loader.remove(backendEntry.id)
     const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
     renameControl.remainingFailures = 1
     await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()

+ 1 - 1
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -86,7 +86,7 @@ describe('PluginInventoryGateway', () => {
       fiberPhase: null,
     })
 
-    await ctx.loader.remove(pendingId)
+    ctx.loader.remove(pendingId)
     expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false)
   })
 

+ 5 - 9
packages/host/webserver/tests/webserver.spec.ts

@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, FiberState } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
 import HttpServer, { renderIndexInjections } from '../src/index.ts'
@@ -363,14 +363,10 @@ describe('real Loader composition', () => {
 
     let second: Context | undefined
     try {
-      let failure: unknown
-      try {
-        await loadComposition(takenPort)
-      } catch (error) {
-        failure = error
-      }
-      second = context
-      expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
+      second = await loadComposition(takenPort)
+      const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
+      expect(entry?.fiber?.state).toBe(FiberState.FAILED)
+      await expect(entry?.fiber?.await()).rejects.toThrow('EADDRINUSE')
     } finally {
       await second?.fiber.dispose()
       context = first

+ 1 - 0
packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts

@@ -194,6 +194,7 @@ describe('DeepSeek plugin package inventory', () => {
 
     await ctx.loader.create({ name: 'versioned-plugin/plugin.mjs' })
     await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(composition).href } })
+    await ctx.loader.await()
 
     const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
     expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([

+ 2 - 2
packages/mcp/mcp-client/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/mcp/mcp-client/README.md
-README.md: de646256888f1c271ad94a4c863338dfb02551e6
-README.zh.md: fe9aadfa00d68b5cea9d5c1f201731544b963b7e
+README.md: 591b93c4bdb922151d246d1eebf46ad0b582cb01
+README.zh.md: 41938dc61b50eaa468cc1915f47bc371107c87e1

+ 1 - 1
packages/mcp/mcp-client/README.md

@@ -67,7 +67,7 @@ Add one entry per server; nothing else is required. After the harness starts, th
 
 The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-mcp-client) is the exhaustive source for every accepted field.
 
-After startup, the server's tools appear as `mcp__<serverName>__<tool>` — try a prompt that uses one. If the initial connection fails, the harness still starts but no tools from that server appear, and an error is logged; set `failOnStartupError: true` to make a startup failure abort the harness instead.
+After startup, the server's tools appear as `mcp__<serverName>__<tool>` — try a prompt that uses one. If the initial connection fails, the harness still starts but no tools from that server appear, and an error is logged. Setting `failOnStartupError: true` rejects plugin activation; [app-boot's startup policy](../../boot/app-boot/README.md) still permits an optional MCP entry to fail without aborting the harness.
 
 ### Tool naming and coexistence
 

+ 1 - 1
packages/mcp/mcp-client/README.zh.md

@@ -67,7 +67,7 @@ kind: "package-reference"
 
 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-mcp-client)是每个受支持字段的穷尽式真源。
 
-启动后,服务器的工具会以 `mcp__<serverName>__<tool>` 形式出现——试着用一条提示词调用其中一个。如果初始连接失败,harness 仍会启动,但该服务器的工具不会出现,并会记录一条错误;设置 `failOnStartupError: true` 可让启动失败改为中止 harness。
+启动后,服务器的工具会以 `mcp__<serverName>__<tool>` 形式出现——试着用一条提示词调用其中一个。如果初始连接失败,harness 仍会启动,但该服务器的工具不会出现,并会记录一条错误。设置 `failOnStartupError: true` 会拒绝插件激活;[app-boot 的启动策略](../../boot/app-boot/README.zh.md)仍允许可选 MCP 配置项失败,而不中止 harness。
 
 ### 工具命名与共存
 

+ 0 - 6
packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml

@@ -1,9 +1,3 @@
-- id: headless-startup
-  disabled: true
-
-- id: headless-runner
-  disabled: true
-
 - insert:
     - id: mcp-pagination-cycle
       name: '@deepseek-ai/dsh-mcp-client'

+ 2 - 2
packages/preset/agent-presets/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/preset/agent-presets/README.md
-README.md: 3f9764206d86ee24b7daad898f08d7b0f8538c8d
-README.zh.md: 11423e94e8dc71e6eac52653d952b2203083574b
+README.md: 07691afa0b0c37327e553df8e55d221bbb9a8bcf
+README.zh.md: 3fa2d633c0ffae84d843795a42ff6d3a5adcc39f

+ 1 - 1
packages/preset/agent-presets/README.md

@@ -127,7 +127,7 @@ This section explains the design behind the roster and the standing mount; obser
 
 ### The mount audit
 
-A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit.
+A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects an import or activation failure, an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit.
 
 ### Authoring mechanics
 

+ 1 - 1
packages/preset/agent-presets/README.zh.md

@@ -127,7 +127,7 @@ agent-presets:
 
 ### 挂载审计
 
-直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。
+直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝导入或激活失败、无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。
 
 ### 创作机制
 

+ 16 - 25
packages/preset/agent-presets/src/mount.ts

@@ -295,24 +295,28 @@ export function serviceForAgent<K extends string & keyof Context>(
 /**
  * Rows that did not reach a usable state, each rendered as one diagnostic line.
  *
- * A row whose module failed to import or whose plugin threw already rejects the
- * mount through the loader; what remains observable here is a row still waiting
- * for a service the composition never supplies.
+ * Wait for the subtree, then report import failures, activation failures, and
+ * rows waiting for services the composition does not supply.
  * @param tree - the mounted subtree.
  * @returns one line per unusable row, empty when every enabled row is usable.
  */
-export function inactiveRows(tree: EntryTree): string[] {
+export async function inactiveRows(tree: EntryTree): Promise<string[]> {
+  await tree.await()
   const lines: string[] = []
   for (const entry of tree.entries()) {
     if (entry.disabled) continue
     const fiber = entry.fiber
-    /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed,
-       so a settled tree never holds an enabled fiber-less entry; the branch exists
-       only because `Entry.fiber` is declared optional. */
     if (fiber === undefined) {
       lines.push(`${entry.options.id} (${entry.options.name}): never started`)
       continue
     }
+    try {
+      await fiber.await()
+    } catch (error) {
+      const detail = mountDetail(error)
+      lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`)
+      continue
+    }
     const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined)
     if (missing.length > 0) {
       lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`)
@@ -324,14 +328,8 @@ export function inactiveRows(tree: EntryTree): string[] {
 /**
  * The causes of `error` whose detail its own message does not already carry.
  *
- * `AggregateError` names none of its causes in its own message, so its
- * `errors` are the branches. The Loader's per-row wrapper takes the opposite
- * approach: it appends `cause.message` to the message it builds and keeps the
- * cause only as `error.cause`, so following a plain chain would print every
- * line twice. That leaves exactly one lossy shape — a wrapped row whose cause
- * is an `AggregateError`. Its message ends with the aggregate's own line and
- * drops the `errors` behind it, which is how a failed group reports as
- * "loader entries failed to apply" and names none of the rows that failed.
+ * Aggregate errors carry separate member messages. A wrapper can preserve the
+ * aggregate as its cause without including those messages in its own text.
  * @param error - the failure to read branches from.
  * @returns the branches to render beneath `error.message`, possibly empty.
  */
@@ -343,19 +341,12 @@ function detailBranches(error: Error): readonly unknown[] {
 /**
  * The reportable text of a mount failure.
  *
- * The loader reports several failed rows as one `AggregateError`, whose own
- * message names none of them; without flattening, a composition that fails on
- * two rows says only "loader entries failed to apply" and the operator has
- * nothing to act on. Nested groups indent under the row that owns them, so a
- * composition failing inside a group still names the rows rather than the
- * group alone.
+ * A plugin may reject with an aggregate or wrap one as its cause. Include its
+ * member messages beneath the row diagnostic so each failure is visible.
  * @param error - the value the mount rejected with.
  * @returns a single-line-per-cause description.
  */
 function mountDetail(error: unknown): string {
-  /* v8 ignore next -- every path into the mount's catch throws an Error: the loader
-     wraps a row's thrown value before it propagates, and this module's own
-     rejections are Errors. The fallback keeps a hostile value readable. */
   if (!(error instanceof Error)) return String(error)
   const branches = detailBranches(error)
   if (branches.length === 0) return error.message
@@ -400,7 +391,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
     /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */
     if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree')
     const { tree, fiber } = subtree
-    const unusable = inactiveRows(tree)
+    const unusable = await inactiveRows(tree)
     if (unusable.length > 0) {
       throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`)
     }

+ 23 - 9
packages/preset/agent-presets/tests/mount.spec.ts

@@ -15,7 +15,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import AgentPresets, {
-  COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
+  COMPOSITION_FILE, inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
 } from '@deepseek-ai/dsh-agent-presets'
 import type { Config } from '@deepseek-ai/dsh-agent-presets'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
@@ -242,6 +242,27 @@ describe('composing a child agent from its parent', () => {
 })
 
 describe('rejecting a composition that cannot be used', () => {
+  it('reports import failures and arbitrary plugin rejections after eager settlement', async () => {
+    ctx.loader.builtins.stringFailure = () => { throw 'string rejection' }
+    ctx.loader.builtins.aggregateFailure = () => {
+      throw new AggregateError([
+        new Error('first member'),
+        new Error('wrapped member', { cause: new AggregateError(['nested member'], 'nested aggregate') }),
+      ], 'aggregate rejection')
+    }
+    await ctx.loader.root.update([
+      { id: 'missing', name: 'cordis:missingBuiltin' },
+      { id: 'disabled', name: 'cordis:missingBuiltin', disabled: true },
+      { id: 'string', name: 'cordis:stringFailure' },
+      { id: 'aggregate', name: 'cordis:aggregateFailure' },
+    ])
+    expect(await inactiveRows(ctx.loader)).toEqual([
+      'missing (cordis:missingBuiltin): never started',
+      'string (cordis:stringFailure): string rejection',
+      'aggregate (cordis:aggregateFailure): aggregate rejection\n- first member\n- wrapped member\n  - nested member',
+    ])
+  })
+
   it('refuses to mount into a context that carries no agent scope', async () => {
     await expect(ctx.agentPresets.mount(ctx, 'standard'))
       .rejects.toThrow(/unscoped context/)
@@ -255,20 +276,13 @@ describe('rejecting a composition that cannot be used', () => {
   })
 
   it('names every failed row, not just the count', async () => {
-    // The Loader folds several failed rows into one AggregateError whose own
-    // message names none of them; unflattened, the operator is told only that
-    // "loader entries failed to apply" and has nothing to act on.
     await expect(agentOn(ctx, 'sess-two-broken', 'two-broken'))
       .rejects.toThrow(/first-refuses[\s\S]*second-refuses/)
   })
 
   it('names the rows inside a failed group, not the group alone', async () => {
-    // The Loader's per-row wrapper keeps only `cause.message`, so a group's
-    // own AggregateError arrives with its `errors` reachable through `cause`
-    // alone. Reading the message stops at "loader entries failed to apply"
-    // and names neither row that actually refused.
     await expect(agentOn(ctx, 'sess-nested-broken', 'nested-broken'))
-      .rejects.toThrow(/outer[\s\S]*inner-first[\s\S]*inner-second/)
+      .rejects.toThrow(/inner-first[\s\S]*inner-second/)
   })
 
   it('names the unresolved service when a row never activates', async () => {

+ 2 - 2
packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts

@@ -73,7 +73,7 @@ function eventTypes(captures: OtlpCapture[]): string[] {
 }
 
 describe('session-telemetry-otel through the production headless profile', () => {
-  it('rejects FULL before any session can be uploaded', async () => {
+  it('continues the headless task without telemetry when FULL is rejected', async () => {
     const { stdout, stderr } = await runLoaderSmoke({
       label: 'session-telemetry-otel rejected FULL loader smoke',
       tempDirPrefix: 'telemetry-otel-full-e2e-',
@@ -82,8 +82,8 @@ describe('session-telemetry-otel through the production headless profile', () =>
       configPath,
       tsconfigPath: repoTsconfig,
       env: { DSH_TELEMETRY_E2E_MODE: 'FULL' },
-      expectedExitCode: 1,
     })
+    expect(stdout + stderr).toContain('warning: 1 entry did not activate')
     expect(stdout + stderr).toContain('FULL')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 

+ 2 - 2
packages/test-support/client-runtime/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/test-support/client-runtime/README.md
-README.md: e32f7858b32e15e5200f9a986cb88e29309c44cb
-README.zh.md: 1b7da6f38723607dedd5eb16a15ade0b974ed135
+README.md: 20f6454012d0d8e4c78d9a0ea0df960d22a71f5a
+README.zh.md: d9c6888f17df3f37e699bb6be0f7ee44ce82d373

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/test-support/client-runtime/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/test-support/client-runtime/README.zh.md


+ 5 - 2
packages/test-support/client-runtime/src/assembly/test-client.ts

@@ -303,11 +303,14 @@ export class TestClient {
   }
 
   /**
-   * Remove one Loader entry.
+   * Remove one Loader entry and wait for its plugin cleanup.
    * @param name - package name of the row.
    */
   async unload(name: string): Promise<void> {
-    await this.ctx.loader.remove(this.entryOf(name).id)
+    const entry = this.entryOf(name)
+    const disposal = entry.fiber?.dispose()
+    this.ctx.loader.remove(entry.id)
+    await disposal
   }
 
   /**

+ 18 - 1
packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts

@@ -136,7 +136,24 @@ describe('TestClient (jsdom)', () => {
     await client.reload(SIDEBAR)
     await client.flush()
     expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
-    await client.unload(SIDEBAR)
+    const cleanupStarted = Promise.withResolvers<undefined>()
+    const releaseCleanup = Promise.withResolvers<undefined>()
+    const sidebar = [...client.ctx.loader.entries()].find(entry => entry.options.name === SIDEBAR)!
+    sidebar.fiber!.ctx.effect(() => async () => {
+      cleanupStarted.resolve(undefined)
+      await releaseCleanup.promise
+    })
+    let unloaded = false
+    const unloading = client.unload(SIDEBAR).then(() => { unloaded = true })
+    try {
+      await cleanupStarted.promise
+      await client.flush()
+      expect(unloaded).toBe(false)
+    } finally {
+      releaseCleanup.resolve(undefined)
+      await unloading
+    }
+    expect(unloaded).toBe(true)
     await client.flush()
     expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0)
     await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`)

+ 13 - 1
packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts

@@ -1,13 +1,15 @@
 #!/usr/bin/env node
 /** Snapshot-only Loader driver: stream one fixture turn as canonical JSONL. */
 
-import type { Context } from '@deepseek-ai/cordis'
+import type { Context, FiberState } from '@deepseek-ai/cordis'
 import { installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
 import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import { bootProductionProfile } from './production-profile.ts'
 
 const NAME = 'headless-test-driver'
+const REQUIRED_ENTRY_ENV = 'DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID'
+const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const [configPath, ...taskParts] = process.argv.slice(2)
 if (configPath === undefined || taskParts.length === 0 || taskParts.every(part => part.trim() === '')) {
   throw new Error(`${NAME}: expected <config-path> <task...>`)
@@ -22,6 +24,16 @@ try {
     profile: 'headless',
     overlayPaths: [resolveConfigPath(configPath, undefined)],
   })
+  const requiredEntryId = process.env[REQUIRED_ENTRY_ENV]
+  if (requiredEntryId !== undefined) {
+    const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === requiredEntryId)
+    if (entry === undefined) throw new Error(`${NAME}: required fixture entry not found: ${requiredEntryId}`)
+    if (entry.fiber === undefined) throw new Error(`${NAME}: required fixture entry failed to import: ${requiredEntryId}`)
+    await entry.fiber.await()
+    if (entry.fiber.state !== FIBER_ACTIVE) {
+      throw new Error(`${NAME}: required fixture entry did not activate: ${requiredEntryId}`)
+    }
+  }
   const result = await runFixtureTurn(ctx, {
     task: taskParts.join(' '),
     onEvent: (sessionId: string, event: SessionEvent) => {

+ 1 - 0
packages/todo/tool-todo/tests/loader-composition.spec.ts

@@ -87,6 +87,7 @@ async function boot(configLines: readonly string[]): Promise<Context> {
   } as unknown as NonNullable<typeof ctx.loader.internal>
   await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
   await ctx.loader.await()
+  for (const entry of ctx.loader.entries()) await entry.fiber?.await()
   return ctx
 }
 

+ 2 - 2
packages/typert/loader/tests/loader.spec.ts

@@ -212,12 +212,12 @@ describe('typert loader', () => {
     await new Promise(resolve => setTimeout(resolve, 20))
     expect(ctx.typert.list()).toHaveLength(1)
 
-    await ctx.loader.remove(id)
+    ctx.loader.remove(id)
     await ctx.loader.await()
     // The unmount reconciliation rides a queued microtask flush.
     await new Promise(resolve => setTimeout(resolve, 20))
     expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined()
-    await ctx.loader.remove(plainId)
+    ctx.loader.remove(plainId)
     await ctx.loader.await()
     await new Promise(resolve => setTimeout(resolve, 20))
 

+ 3 - 3
pnpm-lock.yaml

@@ -1262,6 +1262,9 @@ importers:
       '@deepseek-ai/dsh-package-manifest':
         specifier: workspace:^
         version: link:../../util/package-manifest
+      chokidar:
+        specifier: 4.0.3
+        version: 4.0.3
       js-yaml:
         specifier: ^4.2.0
         version: 4.2.0
@@ -1299,9 +1302,6 @@ importers:
       '@types/js-yaml':
         specifier: ^4.0.9
         version: 4.0.9
-      chokidar:
-        specifier: 4.0.3
-        version: 4.0.3
 
   packages/boot/cmdline:
     devDependencies:

+ 0 - 4
pnpm-workspace.yaml

@@ -14,10 +14,6 @@ packages:
   # closure is what the exe bundles and what the Python runtime distributes.
   - python/sdk-runtime
 
-# Vendored framework packages keep their upstream semver ranges, while local
-# builds must resolve those matching names to this workspace's pinned sources.
-linkWorkspacePackages: true
-
 overrides:
   '@deepseek-ai/cosmokit': 'link:vendor/cosmokit'
   '@deepseek-ai/schemastery': 'link:vendor/schemastery'

+ 2 - 9
scripts/rescope-vendor.ts

@@ -222,15 +222,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [
   {
     id: 'vendor-readme-preamble',
     file: 'vendor/README.md',
-    find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.',
-    replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.',
-    expect: 1,
-  },
-  {
-    id: 'vendor-readme-schemastery-note',
-    file: 'vendor/README.md',
-    find: 'whose lazy `require(\'cosmokit\')` can race',
-    replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race',
+    find: 'All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo.',
+    replace: 'All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges.',
     expect: 1,
   },
   {

+ 1 - 1
scripts/run-gates.spec.ts

@@ -213,7 +213,7 @@ describe('gate graph validation', () => {
       'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints',
       'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
       'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config',
-      'runtime-closure', 'vendored-links',
+      'runtime-closure',
     ])
     expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
       workers: 4,

+ 0 - 1
scripts/run-gates.ts

@@ -286,7 +286,6 @@ export function gatesForMode(selected: Mode): Gate[] {
         ...hygieneLeafGates(),
         pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
         pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
-        pnpmScript('vendored-links', 'verify-vendored-links', { label: 'vendored links' }),
       ]
     case 'doc-sync':
       return docSyncLeafGates()

+ 0 - 72
scripts/verify-vendored-links.ts

@@ -1,72 +0,0 @@
-/**
- * Verify that pnpm-lock.yaml resolves every vendored package name to its
- * workspace `link:` — never a registry copy. `linkWorkspacePackages: true`
- * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the
- * pinned vendored sources; a registry copy of the same name coexisting with
- * the vendored one silently forks the framework layer (vendor/README.md).
- */
-import { readdir, readFile } from 'node:fs/promises'
-import { join, resolve } from 'node:path'
-import * as yaml from 'js-yaml'
-
-const root = resolve(import.meta.dirname, '..')
-
-async function vendoredNames(): Promise<Set<string>> {
-  const names = new Set<string>()
-  for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
-    if (!entry.isDirectory()) continue
-    let manifest: { name?: string }
-    try {
-      manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string }
-    } catch {
-      continue // not a package directory (e.g. vendor/README.md siblings)
-    }
-    if (manifest.name !== undefined) names.add(manifest.name)
-  }
-  return names
-}
-
-interface Lockfile {
-  importers?: Record<string, Record<string, unknown>>
-  packages?: Record<string, unknown>
-  snapshots?: Record<string, unknown>
-}
-
-const names = await vendoredNames()
-if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/')
-const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile
-
-const violations: string[] = []
-
-// Importer resolutions: every dependency entry naming a vendored package must
-// resolve to a link:, or the build silently uses a registry copy.
-for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) {
-  for (const [section, dependencies] of Object.entries(sections)) {
-    if (typeof dependencies !== 'object' || dependencies === null) continue
-    for (const [dependency, entry] of Object.entries(dependencies as Record<string, { version?: string }>)) {
-      if (!names.has(dependency)) continue
-      const version = entry.version ?? ''
-      if (!version.startsWith('link:')) {
-        violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`)
-      }
-    }
-  }
-}
-
-// Package/snapshot keys: a registry copy materializes as a `<name>@<version>`
-// key; vendored names must never appear there at all.
-for (const section of ['packages', 'snapshots'] as const) {
-  for (const key of Object.keys(lockfile[section] ?? {})) {
-    const atIndex = key.lastIndexOf('@')
-    if (atIndex <= 0) continue
-    const packageName = key.slice(0, atIndex)
-    if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`)
-  }
-}
-
-if (violations.length > 0) {
-  console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`)
-  for (const violation of violations) console.error(`  - ${violation}`)
-  process.exit(1)
-}
-console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`)

+ 11 - 11
vendor/README.md

@@ -2,7 +2,7 @@
 
 This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned).
 
-All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory.
+All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges. Upstream MIT `LICENSE` files are preserved in each package directory.
 
 This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md).
 
@@ -31,19 +31,19 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis
 Keep this log exhaustive — every divergence from upstream must be listed.
 
 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions.
-2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages.
+2. **All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. Repository-owned dependencies use the workspace protocol. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes.
 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
-4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
+4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
-6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation.
-7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
-8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
-9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`.
-10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
-11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
-12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`.
+6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
+7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
+8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
+9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/app-boot/src/watch-config.ts`. Covered by `packages/boot/app-boot/tests/watch-config.spec.ts`.
+10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
+11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned when patches are present; without patches, the returned array retains the entry objects. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
+12. **`loader/src/config/entry.ts` activation observer**: the detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit consumer audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`.
 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change.
-14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
+14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`.
 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).

+ 1 - 1
vendor/cordis/src/events.ts

@@ -340,7 +340,7 @@ export interface Events {
   /** Interception hook for a service binding (no core producer). */
   'internal/service'(this: Context, name: string, value: any): void
   /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
-  'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise<void>): void | Promise<void>
+  'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
   /** Waterfall: a service is being read through the context proxy. */
   'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
   /** Waterfall: a service is being written through the context proxy. */

+ 3 - 3
vendor/cordis/src/fiber.ts

@@ -730,8 +730,8 @@ export class Fiber {
    *
    * @param config — the new raw config; validated before anything restarts.
    * @param noSave — hint for persistence hooks not to write the change back.
-   * @returns the update waterfall result; the default restart returns a promise.
-   * @throws when validation, an update listener, or the restarted plugin fails.
+   * @returns nothing; the restart runs behind the `internal/update` waterfall.
+   * @throws {ValidationError} when the new config fails validation.
    */
   update(config: any, noSave = false) {
     this.assertActive()
@@ -745,7 +745,7 @@ export class Fiber {
       return
     }
     config = this._resolveConfig(config)
-    return this.context.waterfall(this, 'internal/update', config, noSave, () => {
+    this.context.waterfall(this, 'internal/update', config, noSave, () => {
       this.config = config
       this._error = undefined
       return this.restart()

Неке датотеке нису приказане због велике количине промена