فهرست منبع

fix(app-boot): preserve published loader composition

imccyu 1 ماه پیش
والد
کامیت
c92a6c3b76

+ 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: 6617e0c167e39d11c2261c9b8f433f1b68185ea7
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7b693b8869613b4352d65ba7d352bd1e9fca448b
+2026-07-24-web-config-tree-boot-and-transport-layering.md: 0aec4714238ed71c6878ce723b55fa7a7434af89
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: b20d29e3265c91aa54eb0b274c1f95353460f46e

+ 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 `dshClient` 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](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; `installFailLoud` remains the process guard for later unhandled rejections.
+**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 `dshClient` 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](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled 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.
 
 **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 runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;`installFailLoud` 继续作为进程级保护,处理之后出现的未处理 rejection
+**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现
 
 **boot 胶水是一对 class。** `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、逐图行 create、settle、sweep。
 

+ 11 - 23
examples/headless-agent/tests/headless.snapshot.ts

@@ -170,29 +170,17 @@ async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
 
 describe('headless stream-json snapshots', () => {
   it('prints the original Loader activation error through the assembled one-shot app', async () => {
-    const label = 'headless startup activation error snapshot'
-    let failure: unknown
-    try {
-      await runLoaderSmoke({
-        label,
-        tempDirPrefix: 'headless-snapshot-startup-error-',
-        binScript,
-        configPath: startupFailureConfigPath,
-        binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
-        tsconfigPath,
-      })
-    } catch (error) {
-      failure = error
-    }
-    expect(failure).toBeInstanceOf(Error)
-    const message = (failure as Error).message
-    const prefix = `${label} exited 1. stdout:\n`
-    const stderrMarker = '\nstderr:\n'
-    expect(message.startsWith(prefix)).toBe(true)
-    const stderrAt = message.indexOf(stderrMarker, prefix.length)
-    expect(stderrAt).toBeGreaterThanOrEqual(prefix.length)
-    expect(message.slice(prefix.length, stderrAt)).toBe('')
-    await expect(message.slice(stderrAt + stderrMarker.length)).toMatchFileSnapshot(startupFailureExpected)
+    const result = await runLoaderSmoke({
+      label: 'headless startup activation error snapshot',
+      tempDirPrefix: 'headless-snapshot-startup-error-',
+      binScript,
+      configPath: startupFailureConfigPath,
+      binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
+      tsconfigPath,
+      expectedExitCode: 1,
+    })
+    expect(result.stdout).toBe('')
+    await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('retries a transient provider failure through the one-shot app', async () => {

+ 1 - 0
packages/host/directory-picker-auto/tests/loader-composition.spec.ts

@@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
     '  config:',
     `    host: '${bindHost}'`,
     '    port: 0',
+    '    portConflict: increment',
     `    distIndex: '${distIndex}'`,
     `- name: '${AUTO}'`,
     '',

+ 2 - 2
packages/ui/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/ui/app-boot/README.md
-README.md: 55211988a7687ba52f13d30931e042f0823e2526
-README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6
+README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
+README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42

+ 2 - 2
packages/ui/app-boot/README.md

@@ -17,9 +17,9 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
 | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
 | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
 
-Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot.
+Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
 
-Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
+Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
 
 This 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; built consumers continue to use plain Node package resolution.
 

+ 2 - 2
packages/ui/app-boot/README.zh.md

@@ -17,9 +17,9 @@
 | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
 | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
 
-Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 rejection
+Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命
 
-配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
+配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
 
 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
 

+ 46 - 8
packages/ui/app-boot/src/index.ts

@@ -160,16 +160,46 @@ export interface FailLoudProcess {
   exit(code: number): void
 }
 
+// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
+// exact reasons already folded into the boot diagnostic visible through the
+// next process rejection checkpoint so the process guard can coalesce them.
+const assembledActivationRejections = new Map<unknown, number>()
+
+function retainAssembledRejection(reason: unknown): void {
+  assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
+}
+
+function releaseAssembledRejection(reason: unknown): void {
+  const count = assembledActivationRejections.get(reason)
+  if (count === undefined || count === 1) {
+    assembledActivationRejections.delete(reason)
+  } else {
+    assembledActivationRejections.set(reason, count - 1)
+  }
+}
+
+async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
+  for (const reason of reasons) retainAssembledRejection(reason)
+  try {
+    await new Promise<void>(resolve => setImmediate(resolve))
+  } finally {
+    for (const reason of reasons) releaseAssembledRejection(reason)
+  }
+}
+
 /**
  * Install before boot to turn a late unhandled plugin-init rejection into one
- * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
- * the returned function removes the handler.
+ * labelled stderr diagnostic and `exit(1)`. A rejection already included by
+ * {@link assertEntriesActivated} is ignored during its process checkpoint;
+ * every other rejection remains fatal. Stdout remains untouched for ACP; the
+ * returned function removes the handler.
  * @param binName - the diagnostic prefix on the fatal-failure line.
  * @param proc - the process slice to register on; tests inject a fake.
  * @returns the uninstaller that removes the rejection handler.
  */
 export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
   const handler = (err: unknown): void => {
+    if (assembledActivationRejections.has(err)) return
     proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
     proc.exit(1)
   }
@@ -212,17 +242,20 @@ function formatActivationError(error: unknown): string {
  * @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 when an entry failed to import, rejected during activation, or did not become active.
+ * @throws after one process rejection checkpoint when an entry failed to
+ * import, rejected during activation, or did not become active.
  */
 export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
   assertEntriesLoaded(ctx, binName)
   const failures: string[] = []
+  const rejectionReasons: unknown[] = []
   for (const entry of ctx.loader.entries()) {
     const fiber = entry.fiber
     if (fiber === undefined || entry.disabled) continue
     try {
       await fiber.await()
     } catch (error) {
+      rejectionReasons.push(error)
       failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
       continue
     }
@@ -237,6 +270,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
     }
   }
   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')}`)
   }
@@ -250,11 +286,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * bootstrap include is therefore statically imported and mounted as the
  * `cordis:include` builtin, loading through the ambient module pipeline
  * (vite/tsx/plain ESM) while the included tree's own specifiers stay
- * config-relative. A missing fiber rejects here; a later init rejection is
- * rethrown with its original stack by {@link assertEntriesActivated}; later
- * unhandled rejections remain covered by {@link installFailLoud}. Built bins
- * need the Loader's native helper for bare plugin specifiers; relative
- * specifiers do not.
+ * config-relative. The package build embeds Include while leaving Loader
+ * external, so the built include tree and host share one Loader peer. A
+ * missing fiber rejects here; a later init rejection is rethrown with its
+ * original stack by {@link assertEntriesActivated}; 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
  * (see {@link resolveConfigPath}).

+ 27 - 14
packages/ui/app-boot/tests/app-boot.spec.ts

@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
     uninstallReal()
     expect(process.listenerCount('unhandledRejection')).toBe(before)
   })
+
+  it('does not report an activation rejection shared by entries in the boot audit', async () => {
+    const proc = fakeProc()
+    installFailLoud(NAME, proc)
+    const error = new Error('assembled activation failure')
+    const audit = assertEntriesActivated({
+      loader: {
+        entries: () => ['broken-a', 'broken-b'].map(name => ({
+          options: { name },
+          fiber: {
+            state: 3,
+            inject: {},
+            ctx: { get: () => undefined },
+            await: async () => { throw error },
+          },
+        })),
+      },
+    } as unknown as Context, NAME)
+    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')
+    proc.handlers[0]!(error)
+    expect(proc.exits).toEqual([1])
+  })
 })
 
 describe('assertEntriesLoaded', () => {
@@ -300,20 +327,6 @@ describe('boot', () => {
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
   })
 
-  it('reports an activation error from a real Loader fiber instead of its numeric state', async () => {
-    const dir = tmp()
-    writeFileSync(join(dir, 'broken.mjs'), 'export function apply() { throw new Error("real activation failure") }\n')
-    writeFileSync(join(dir, 'cordis.yml'), '- id: broken\n  name: ./broken.mjs\n')
-    let thrown: unknown
-    try {
-      await boot(NAME, join(dir, 'cordis.yml'))
-    } catch (error) {
-      thrown = error
-    }
-    expect(String(thrown)).toContain(`${NAME}: 1 entry did not activate\n./broken.mjs: Error: real activation failure`)
-    expect(String(thrown)).not.toContain('fiber state 3')
-  })
-
   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')

+ 19 - 0
packages/ui/app-boot/tsdown.config.ts

@@ -0,0 +1,19 @@
+import { defineConfig } from 'tsdown'
+
+/**
+ * Embed Include while keeping Loader external so the built include tree and
+ * app host bind to one Loader peer.
+ */
+export default defineConfig({
+  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'node',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+  deps: {
+    alwaysBundle: ['@cordisjs/plugin-include'],
+  },
+})

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
vendor/README.md


+ 2 - 3
vendor/loader/src/config/entry.ts

@@ -160,11 +160,10 @@ export class Entry {
     } finally {
       this._initTask = undefined
     }
-    const notify = () => {
+    this.fiber?.await().finally(() => {
       if (this.loader.getTasks().length) return
       this.ctx.reflect.notify(['loader'])
-    }
-    void this.fiber?.await().then(notify, notify)
+    })
   }
 
   private async _init() {

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است