Browse Source

fix(cli): complete startup diagnostics before exiting

turtle1999 1 month ago
parent
commit
053cad9442

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

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

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

@@ -30,6 +30,8 @@ This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transpor
 
 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 combine every inactive entry into one diagnostic, separating failed plugins from pending services and marking required entries. `StartupError` retains the original failures as its cause after app-boot disposes the root. The CLI prints its message once and exits with code 1, avoiding duplicate wrapper stacks while preserving plugin stacks, nested causes, and aggregate members. The CLI saves original errors, inactive-entry metadata, and startup warning/error records in a unique report directly under `$DSH_HOME/logs/`. This preserves import errors and error properties that the concise terminal output omits. Failed writes fall back to the full report on stderr and keep exit code 1. Unrelated exceptions remain unhandled.
 
+The compact terminal report keeps the failing plugins visible; a separate file retains raw diagnostics without the default logger buffer's record limit. Raw error values remain intact, so a sharing warning accompanies the report rather than silently redacting fields. An independent exporter lifetime covers asynchronous application disposal. The CLI awaits stderr completion before explicitly exiting, because failed plugins can leave stdin or other handles open.
+
 ## 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. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, 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.

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

@@ -30,6 +30,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
 
 稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 将所有 inactive entry 合并到一份诊断中,区分失败插件与等待服务的插件,并标记 required entry。App-boot 拆卸 root 后,`StartupError` 仍以 cause 保留原始失败。CLI 仅输出其消息一次,并以退出码 1 结束,避免重复的包装堆栈,同时保留插件堆栈、嵌套原因和聚合错误成员。CLI 将原始错误、未激活条目的元数据及启动警告、错误记录保存到直接位于 `$DSH_HOME/logs/` 下的唯一报告中,保留简洁终端输出省略的导入错误和错误属性。写入失败时,完整报告回退到 stderr,退出码仍为 1。其他异常继续作为未处理异常抛出。
 
+简洁的终端报告突出失败插件;单独文件保存原始诊断,不受默认 logger 缓冲区记录数限制。原始错误值保持完整,因此报告附带分享提醒,不会静默脱敏字段。独立的 exporter 生命周期覆盖应用的异步资源释放。CLI 等待 stderr 写入完成后明确退出,因为失败插件可能留下 stdin 或其他打开的句柄。
+
 ## 测试
 
 App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。

+ 2 - 2
apps/cli/reference/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 apps/cli/reference/README.md
-README.md: 176e1693ad4beb156f83538a2b12279e48783529
-README.zh.md: cc58d110638a26cf466fc09b3adc29d51109da31
+README.md: 6429b2872ec393304a7d3a76afd8194cb74d7698
+README.zh.md: c9d9bee8ad84e3fb0c3f4d82b295c743f5a5ad99

+ 2 - 2
apps/cli/reference/README.md

@@ -53,9 +53,9 @@ dsh --profile web --patch ./extra.yml --dump-config
 <a id="startup-diagnostics"></a>
 ## Startup diagnostics
 
-A required plugin activation failure prints failed plugins and their original stacks, followed by pending plugins and missing services. Required plugins appear first in the pending list. The final `Full diagnostics:` line names a unique `startup-<timestamp>-<uuid>.log` file directly under `$DSH_HOME/logs/` (default `~/.dsh/logs/`). The CLI exits with code 1 after saving the report; it does not overwrite earlier reports or delete them automatically.
+A required plugin activation failure prints failed plugins and their original stacks, followed by pending plugins and missing services. Required plugins appear first in the pending list. The final `Full diagnostics:` line names a unique `startup-<timestamp>-<uuid>.log` file directly under `$DSH_HOME/logs/` (default `~/.dsh/logs/`). The CLI completes report and stderr writes before explicitly exiting with code 1, even if plugin handles remain open; it does not overwrite earlier reports or delete them automatically.
 
-The report includes DSH and Node versions, platform, profile, root configuration path, every inactive plugin's module and state, original errors, and startup warning/error arguments, including import errors that have no Fiber. Node inspection preserves nested causes, aggregate members, circular references, non-enumerable properties, and Symbol properties with depth, string, and array limits disabled. Custom inspectors are disabled and accessors are described without evaluating them. The collector stops when boot settles and does not collect environment variables or configuration contents independently of logged errors.
+The report includes DSH and Node versions, platform, profile, root configuration path, every inactive plugin's module and state, original errors, and startup warning/error arguments, including import errors that have no Fiber. Node inspection preserves nested causes, aggregate members, circular references, non-enumerable properties, and Symbol properties with depth, string, and array limits disabled. Custom inspectors are disabled and accessors are described without evaluating them. The collector includes asynchronous failure cleanup and stops when boot settles. It does not collect environment variables or configuration contents independently of logged errors. Raw plugin errors can contain configuration or credential values; the report header warns readers to review it before sharing. Values are not redacted.
 
 New directories and files request modes `0700` and `0600` on POSIX. The CLI prints a file path only after writing succeeds. If the logs directory or file cannot be written, stderr includes the write error and full report, and the exit code remains 1. Optional-only activation warnings retain their normal output without creating a report.
 

+ 2 - 2
apps/cli/reference/README.zh.md

@@ -55,9 +55,9 @@ dsh --profile web --patch ./extra.yml --dump-config
 <a id="startup-diagnostics"></a>
 ## 启动诊断
 
-必需插件激活失败时,CLI 先输出失败插件及其原始堆栈,再列出等待中的插件和缺失服务。等待列表中的必需插件排在前面。末尾的 `Full diagnostics:` 行指向直接位于 `$DSH_HOME/logs/`(默认 `~/.dsh/logs/`)下的唯一 `startup-<timestamp>-<uuid>.log` 文件。CLI 保存报告后以退出码 1 结束;不会覆盖以前的报告,也不会自动删除它们。
+必需插件激活失败时,CLI 先输出失败插件及其原始堆栈,再列出等待中的插件和缺失服务。等待列表中的必需插件排在前面。末尾的 `Full diagnostics:` 行指向直接位于 `$DSH_HOME/logs/`(默认 `~/.dsh/logs/`)下的唯一 `startup-<timestamp>-<uuid>.log` 文件。CLI 完成报告和 stderr 写入后会明确以退出码 1 结束,即使插件仍有打开的句柄;不会覆盖以前的报告,也不会自动删除它们。
 
-报告包含 DSH 和 Node 版本、平台、profile、根配置路径、每个未激活插件的模块与状态、原始错误,以及启动期间的警告和错误参数,包括尚无 Fiber 的导入错误。Node 检查输出保留嵌套原因、聚合成员、循环引用、不可枚举属性和 Symbol 属性,并关闭深度、字符串及数组长度限制。自定义检查函数被禁用,访问器只描述而不求值。收集器在启动结算后停止,不会独立于已记录错误额外收集环境变量或配置内容。
+报告包含 DSH 和 Node 版本、平台、profile、根配置路径、每个未激活插件的模块与状态、原始错误,以及启动期间的警告和错误参数,包括尚无 Fiber 的导入错误。Node 检查输出保留嵌套原因、聚合成员、循环引用、不可枚举属性和 Symbol 属性,并关闭深度、字符串及数组长度限制。自定义检查函数被禁用,访问器只描述而不求值。收集器包含失败后的异步清理日志,并在启动结算后停止。它不会独立于已记录错误额外收集环境变量或配置内容。插件原始错误可能包含配置或凭据值;报告开头会提醒读者在分享前检查内容。报告不脱敏。
 
 在 POSIX 上,新目录和文件分别请求 `0700` 和 `0600` 权限。CLI 仅在写入成功后输出文件路径。如果日志目录或文件无法写入,stderr 会包含写入错误及完整报告,退出码仍为 1。只有可选插件激活异常时,维持正常警告输出,不创建报告。
 

+ 1 - 1
apps/cli/src/bin.ts

@@ -45,7 +45,7 @@ export async function runCli(): Promise<void> {
       } catch (error) {
         if (!(error instanceof StartupError)) throw error
         await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile })
-        process.exitCode = 1
+        process.exit(1)
       }
       break
     }

+ 17 - 7
apps/cli/src/startup-diagnostics.ts

@@ -13,21 +13,31 @@ interface StartupDiagnosticContext {
   profile: string
 }
 
+/** Wait for stderr to finish the write before the failed process exits. */
+function writeStderr(text: string): Promise<void> {
+  return new Promise((resolve, reject) => {
+    process.stderr.write(text, (error) => {
+      if (error) reject(error)
+      else resolve()
+    })
+  })
+}
+
 /**
  * Print the startup summary and save a private, uniquely named report under DSH_HOME/logs.
  * Failed writes print the complete report to stderr instead of claiming a saved path.
  * @param error - startup audit failure retaining plugin metadata and original errors.
  * @param context - resolved Harness home, application version, and selected profile.
- * @param write - terminal output sink; defaults to stderr.
- * @returns after the report is saved or its write failure and content have been printed.
+ * @param write - terminal output sink; awaited before returning, defaults to stderr.
+ * @returns after saving or printing the report and completing terminal writes.
  */
 export async function reportStartupFailure(
   error: StartupError,
   context: StartupDiagnosticContext,
-  write: (text: string) => void = text => void process.stderr.write(text),
+  write: (text: string) => void | Promise<void> = writeStderr,
 ): Promise<void> {
   const now = new Date().toISOString()
-  const report = inspect({
+  const report = 'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n' + inspect({
     timestamp: now,
     dshVersion: context.version,
     nodeVersion: process.version,
@@ -44,15 +54,15 @@ export async function reportStartupFailure(
     getters: false,
     colors: false,
   }) + '\n'
-  write(`${error.message}\n`)
+  await write(`${error.message}\n`)
   const logDir = join(context.home, 'logs')
   const logPath = join(logDir, `startup-${now.replaceAll(':', '-')}-${randomUUID()}.log`)
   try {
     await mkdir(logDir, { recursive: true, mode: 0o700 })
     await writeFile(logPath, report, { flag: 'wx', mode: 0o600 })
   } catch (writeError) {
-    write(`\ndsh: warning: could not write startup diagnostics: ${String(writeError)}\nFull diagnostics:\n${report}`)
+    await write(`\ndsh: warning: could not write startup diagnostics: ${String(writeError)}\nFull diagnostics:\n${report}`)
     return
   }
-  write(`\nFull diagnostics: ${logPath}\n`)
+  await write(`\nFull diagnostics: ${logPath}\n`)
 }

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

@@ -1,5 +1,5 @@
 import { createServer } from 'node:http'
-import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -334,6 +334,47 @@ describe('Python SDK dsh profile keyless smoke', () => {
     }
   }, 40_000)
 
+  it.each([false, true])('exits after startup failure with stdin open (logs blocked: %s)', async (blocked) => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-startup-exit-'))
+    const home = join(root, '.dsh')
+    const patch = join(root, 'failure.yml')
+    await mkdir(home)
+    if (blocked) await writeFile(join(home, 'logs'), 'blocked')
+    await writeFile(patch, '- id: agent-loop\n  config:\n    maxParallelToolCalls: 0\n')
+    const child = execa(process.execPath, [
+      '--import', 'tsx/esm', binScript, '--profile', 'sdk', '--patch', patch,
+    ], {
+      cwd: repoRoot,
+      env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
+      stdin: 'pipe',
+      stripFinalNewline: false,
+      timeout: 25_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
+    try {
+      const result = await child
+      expect(result.timedOut, result.stderr).toBe(false)
+      expect(result.signal, result.stderr).toBeUndefined()
+      expect(result.exitCode, result.stderr).toBe(1)
+      expect(result.stderr).toContain('startup failed:')
+      expect(result.stderr).toContain('maxParallelToolCalls')
+      if (blocked) {
+        expect(result.stderr).toContain('Full diagnostics:\nWARNING: Raw diagnostics')
+        expect(result.stderr.trimEnd()).toMatch(/\}$/u)
+      } else {
+        const files = await readdir(join(home, 'logs'))
+        expect(files).toHaveLength(1)
+        expect(result.stderr).toContain(`Full diagnostics: ${join(home, 'logs', files[0]!)}\n`)
+      }
+    } finally {
+      child.stdin.end()
+      child.kill('SIGKILL')
+      await child
+      await rm(root, { recursive: true, force: true })
+    }
+  }, 30_000)
+
   it('rejects an invalid max-token success env value', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
     try {

+ 40 - 3
apps/cli/tests/startup-diagnostics.spec.ts

@@ -27,11 +27,45 @@ function startupError(reason: unknown): StartupError {
 describe('startup diagnostic files', () => {
   it('prints the summary and saved path to stderr by default', async () => {
     const dir = await home()
-    const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
+    const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
+      if (typeof callback === 'function') callback()
+      return true
+    })
     onTestFinished(() => { write.mockRestore() })
     await reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })
-    expect(write).toHaveBeenCalledWith(expect.stringContaining('dsh: startup failed:'))
-    expect(write).toHaveBeenCalledWith(expect.stringContaining(`Full diagnostics: ${join(dir, 'logs')}`))
+    expect(write).toHaveBeenCalledWith(expect.stringContaining('dsh: startup failed:'), expect.any(Function))
+    expect(write).toHaveBeenCalledWith(expect.stringContaining(`Full diagnostics: ${join(dir, 'logs')}`), expect.any(Function))
+  })
+
+  it('waits for stderr completion before resolving', async () => {
+    const dir = await home()
+    const pending: Array<() => void> = []
+    const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
+      if (typeof callback === 'function') pending.push(() => { callback() })
+      return false
+    })
+    onTestFinished(() => { write.mockRestore() })
+    let finished = false
+    const report = reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })
+      .then(() => { finished = true })
+    expect(pending).toHaveLength(1)
+    pending.shift()!()
+    await vi.waitFor(() => { expect(pending).toHaveLength(1) })
+    expect(finished).toBe(false)
+    pending.shift()!()
+    await report
+    expect(finished).toBe(true)
+  })
+
+  it('rejects when stderr cannot complete the write', async () => {
+    const dir = await home()
+    const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
+      if (typeof callback === 'function') callback(new Error('stderr closed'))
+      return false
+    })
+    onTestFinished(() => { write.mockRestore() })
+    await expect(reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' }))
+      .rejects.toThrow('stderr closed')
   })
 
   it('retains original error properties, causes, aggregate members, cycles, and long values', async () => {
@@ -57,6 +91,9 @@ describe('startup diagnostic files', () => {
     const path = join(dir, 'logs', files[0]!)
     expect(chunks.join('')).toBe(`${error.message}\n\nFull diagnostics: ${path}\n`)
     const report = await readFile(path, 'utf8')
+    expect(report.startsWith(
+      'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n',
+    )).toBe(true)
     for (const text of [
       'dshVersion: \'1.2.3\'', "profile: 'web'", process.version, 'configurationPath:', '/example/cordis.yml',
       "module: './waiting.mjs'", 'required: false', 'fiberState: 0', "missing: [ 'webServer'", 'messages:',

+ 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: 7237f0cd176f4092407f8944703276999e7f3f0c
-README.zh.md: b87072bfeeac22268a7baa2ff5646dc737c9b02f
+README.md: f4417e8a9c6266a09347052dda8f0af9833ff715
+README.zh.md: c04d53b3e26b65043501480fc5f2dde808e40320

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

@@ -69,7 +69,7 @@ Before you boot, you can print the exact configuration the app will mount: the d
 
 Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation.
 
-After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
+After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
 
 | Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR |
 |---|---|---|---|
@@ -115,7 +115,7 @@ This section explains how the outcomes above are realized and points at the code
 - **Application-owned profiles.** Link mode projects missing installation and bundle packages inside the profile without writing a shared Harness-home fallback. Runtime mode supplies the same installation and bundle generation without creating links. Package operations remove only profile links owned by dsh; pnpm-managed entries remain untouched.
 - **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned generation in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection.
 - **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.
-- **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.
+- **One rejection checkpoint.** `inactiveEntries` 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.** Outside startup audit failures, `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. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
 
 The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles.

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

@@ -69,7 +69,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。
 
-Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
+Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
 
 | 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR |
 |---|---|---|---|
@@ -115,7 +115,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
 - **应用自有 profile。** link 模式在 profile 内投影缺失的安装包及 bundle 包,不写共享的 Harness-home 后备目录。runtime 模式提供相同的安装包及 bundle generation,不创建链接。包操作仅移除 dsh 所有的 profile 链接;pnpm 管理的条目保持不变。
 - **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 generation。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。
 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
-- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
+- **单一 rejection 检查点。** `inactiveEntries` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
 
 启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。

+ 5 - 2
packages/boot/app-boot/src/index.ts

@@ -919,7 +919,10 @@ export async function boot(
 ): Promise<Context> {
   const ctx = new Context()
   const startupLogs: StartupLogRecord[] = []
-  const stopStartupLogs = ctx.logger.exporter({
+  // The collector must outlive root disposal to retain asynchronous cleanup errors.
+  const diagnostics = new Context()
+  diagnostics.logger = ctx.logger
+  diagnostics.logger.exporter({
     levels: { default: 2 },
     export: ({ ts, name, type, args }) => {
       if (type === 'warn' || type === 'error') startupLogs.push({ ts, name, type, args })
@@ -971,7 +974,7 @@ export async function boot(
       : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
     throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
   } finally {
-    await stopStartupLogs()
+    await diagnostics.fiber.dispose()
   }
 }
 

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

@@ -775,6 +775,42 @@ describe('boot', () => {
     expect(error.startup?.messages.some(message => message.args.some(arg => arg instanceof Error && arg.message.includes('missing.mjs')))).toBe(true)
   })
 
+  it('retains warnings and errors from asynchronous failed-startup cleanup', async () => {
+    const dir = tmp()
+    const marker = join(dir, 'cleanup.txt')
+    writeFileSync(join(dir, 'cleanup.mjs'), `
+      import { writeFileSync } from 'node:fs'
+      export function apply(ctx) {
+        ctx.effect(() => async () => {
+          await Promise.resolve()
+          writeFileSync(${JSON.stringify(marker)}, 'ran')
+          ctx.logger.warn('plugin cleanup warning')
+          throw new Error('plugin cleanup error')
+        })
+      }
+    `)
+    const config = join(dir, 'cordis.yml')
+    writeFileSync(config, '- id: cleanup\n  name: ./cleanup.mjs\n- id: webserver\n  name: ./missing.mjs\n')
+    let root!: Context
+    const failure = await boot(NAME, config, undefined, (ctx) => {
+      root = ctx
+      ctx.effect(() => async () => {
+        await Promise.resolve()
+        ctx.logger.warn('root cleanup warning')
+      })
+    }).catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(StartupError)
+    expect(readFileSync(marker, 'utf8')).toBe('ran')
+    const messages = (failure as StartupError).startup!.messages
+    const args = messages.flatMap(message => message.args)
+    expect(args).toContain('plugin cleanup warning')
+    expect(args).toContain('root cleanup warning')
+    expect(args.some(value => value instanceof Error && value.message.includes('plugin cleanup error'))).toBe(true)
+    const count = messages.length
+    root.logger.warn('after boot rejected')
+    expect(messages).toHaveLength(count)
+  })
+
   it('stops collecting startup diagnostics after a successful boot', async () => {
     const dir = tmp()
     const config = join(dir, 'cordis.yml')