Просмотр исходного кода

fix: preserve plugin activation errors

imccyu 1 месяц назад
Родитель
Сommit
a691e2ab16

+ 6 - 0
examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs

@@ -0,0 +1,6 @@
+/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */
+export function apply() {
+  const failure = new Error('startup activation snapshot failure')
+  failure.stack = 'Error: startup activation snapshot failure\n    at activation-error-fixture'
+  throw failure
+}

+ 2 - 0
examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml

@@ -0,0 +1,2 @@
+- id: activation-error
+  name: ./activation-error.mjs

+ 28 - 0
examples/headless-agent/tests/headless.snapshot.ts

@@ -33,6 +33,8 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
 const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
 const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
 const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
+const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
+const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt')
 const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
 const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
@@ -167,6 +169,32 @@ 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)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('retries a transient provider failure through the one-shot app', async () => {
     const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
     const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')

+ 3 - 0
examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt

@@ -0,0 +1,3 @@
+dsh-cli-demo: dsh-cli-demo: 1 entry did not activate
+./activation-error.mjs: Error: startup activation snapshot failure
+    at activation-error-fixture

+ 16 - 3
packages/client/modules/src/index.ts

@@ -58,6 +58,9 @@ interface PkgMeta {
   immediately: boolean
 }
 
+/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
+const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
+
 /** Missing built client export, retained as structured data for activation-error grouping. */
 class MissingClientBundleError extends Error {
   constructor(
@@ -66,7 +69,11 @@ class MissingClientBundleError extends Error {
     cause: unknown,
   ) {
     super(
-      `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`,
+      [
+        `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
+        `  package: ${packageName}`,
+        `  path: ${clientPath}`,
+      ].join('\n'),
       { cause },
     )
   }
@@ -80,7 +87,7 @@ class ClientPackageCompositionError extends AggregateError {
     const packageNoun = failures.length === 1 ? 'package' : 'packages'
     const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
     if (missingBundles.length > 0) {
-      lines.push('  client packages requiring a build before source launch:')
+      lines.push(`  client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
       for (const error of missingBundles) {
         lines.push(`    - package: ${error.packageName}`, `      path: ${error.clientPath}`)
       }
@@ -353,7 +360,13 @@ export class ClientModuleHostService extends Service {
     return meta
   }
 
-  /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */
+  /**
+   * Read the activation-time bundle revision.
+   * @param pkgName - package that declares the client bundle.
+   * @param clientPath - absolute path of the built client artifact.
+   * @returns the bundle content's short hash for use as its revision.
+   * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
+   */
   private initialBundleRevision(pkgName: string, clientPath: string): string {
     try {
       return shortHash(readFileSync(clientPath))

+ 4 - 2
packages/client/modules/tests/node-half.spec.ts

@@ -61,7 +61,7 @@ describe('client bundle activation', () => {
     const secondPath = writePackage(secondName)
     expect(() => construct([firstName, secondName])).toThrow([
       'client-modules: 2 client packages failed to compose:',
-      '  client packages requiring a build before source launch:',
+      '  client bundles not found; run `pnpm run build` before launch:',
       `    - package: ${firstName}`,
       `      path: ${firstPath}`,
       `    - package: ${secondName}`,
@@ -79,7 +79,9 @@ describe('client bundle activation', () => {
     } catch (error) {
       thrown = error
     }
+    expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
+    expect(String(thrown)).toContain('  other failures:')
     expect(String(thrown)).toContain('EISDIR')
-    expect(String(thrown)).not.toContain('requiring a build before source launch')
+    expect(String(thrown)).not.toContain('pnpm run build')
   })
 })

+ 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: 7c4b258de4e5cc837ea7fa2aa8448feb421c3661
-README.zh.md: b1b12217792217bd1b29bd0752dd41afd369d5a6
+README.md: 55211988a7687ba52f13d30931e042f0823e2526
+README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6

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

@@ -10,14 +10,14 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
 | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
 | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
 | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
-| `assertEntriesActivated(ctx, binName)` | Await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
+| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
 | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
 | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
 | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
 | `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 |
 
-The guards preserve two Loader failure classes. 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. `installFailLoud` remains the process guard for rejections that escape after boot.
 
 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.
 

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

@@ -10,14 +10,14 @@
 | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
 | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
 | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
-| `assertEntriesActivated(ctx, binName)` | Loader 结算后等待每个已启用条目;抛出的异常包含每个失败插件的原始 stack,或每个 pending 插件尚未解析的服务 |
+| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
 | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
 | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
 | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
 | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
 | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
 
-这些保护保留 Loader 的两类故障。插件导入失败会留下没有 fiber 的条目,`assertEntriesLoaded` 将它转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下 failed fiber,因为 `loader.await()` 只结算生命周期任务、不向外传播该错误;`assertEntriesActivated` 显式等待 fiber,并在启动 rejection 中包含原始 stack。`installFailLoud` 仍负责拦截 boot 之后逃逸的 rejection。
+Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 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()`。
 

+ 19 - 22
packages/ui/app-boot/tests/app-boot.spec.ts

@@ -300,31 +300,28 @@ describe('boot', () => {
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
   })
 
-  it('rejects a settled tree with a pending inject and names every missing service', async () => {
+  it('reports an activation error from a real Loader fiber instead of its numeric state', async () => {
     const dir = tmp()
-    writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\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('./waiting.mjs: pending (waiting for services: alpha, beta)')
-  })
-
-  it('uses singular diagnostics for one missing pending dependency', () => {
-    const ctx = {
-      loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
-      get: () => undefined,
-    } as unknown as Context
-    expect(() =>{  assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
+    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 unknown pending dependencies and unexpected fiber states', () => {
-    const entries = [
-      { disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
-      { disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
-    ]
-    const ctx = {
-      loader: { entries: () => entries },
-      get: () => undefined,
-    } as unknown as Context
-    expect(() =>{  assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: 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')
+    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'))
   })
 })
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
vendor/README.md


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

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

Некоторые файлы не были показаны из-за большого количества измененных файлов