Pārlūkot izejas kodu

fix(cli): save full startup diagnostics under DSH_HOME logs

turtle1999 3 nedēļas atpakaļ
vecāks
revīzija
18260e3b0c

+ 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: b8c046e33495b0334593362fd083fefd9c7343c0
-2026-09-09-consumer-owned-startup-strictness.zh.md: 81c1fa4a6e2af620a5c3fd1b91957cf508230834
+2026-09-09-consumer-owned-startup-strictness.md: c5bfa036c1652b17a38b22094e44c756638ad4a5
+2026-09-09-consumer-owned-startup-strictness.zh.md: da893b362292f7b8b058b6ed3ed47adc054675d8

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

@@ -28,11 +28,11 @@ This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transpor
 
 ## 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 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. Unrelated exceptions remain unhandled.
+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.
 
 ## 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 and preservation of original error objects. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, 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.
+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.
 
 The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together.
 

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

@@ -28,11 +28,11 @@ 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 结束,避免重复的包装堆栈,同时保留插件堆栈、嵌套原因和聚合错误成员。其他异常继续作为未处理异常抛出。
+稳定的 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。其他异常继续作为未处理异常抛出。
 
 ## 测试
 
-App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组和原始错误对象的保留行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
+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` 无法激活时以非零码退出,且不报告就绪。
 
 [Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
 

+ 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: dd8256fbe81d8a0e3d6993875f577cbc6f8b027e
-README.zh.md: a7d9417227feb59cfca9bb04167e5258a6840d69
+README.md: 176e1693ad4beb156f83538a2b12279e48783529
+README.zh.md: cc58d110638a26cf466fc09b3adc29d51109da31

+ 9 - 0
apps/cli/reference/README.md

@@ -50,6 +50,15 @@ dsh --profile web --patch ./extra.yml --dump-config
 
 `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
 
+<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.
+
+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.
+
+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.
+
 ## Plugin management
 
 `dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.

+ 9 - 0
apps/cli/reference/README.zh.md

@@ -52,6 +52,15 @@ dsh --profile web --patch ./extra.yml --dump-config
 
 `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
 
+<a id="startup-diagnostics"></a>
+## 启动诊断
+
+必需插件激活失败时,CLI 先输出失败插件及其原始堆栈,再列出等待中的插件和缺失服务。等待列表中的必需插件排在前面。末尾的 `Full diagnostics:` 行指向直接位于 `$DSH_HOME/logs/`(默认 `~/.dsh/logs/`)下的唯一 `startup-<timestamp>-<uuid>.log` 文件。CLI 保存报告后以退出码 1 结束;不会覆盖以前的报告,也不会自动删除它们。
+
+报告包含 DSH 和 Node 版本、平台、profile、根配置路径、每个未激活插件的模块与状态、原始错误,以及启动期间的警告和错误参数,包括尚无 Fiber 的导入错误。Node 检查输出保留嵌套原因、聚合成员、循环引用、不可枚举属性和 Symbol 属性,并关闭深度、字符串及数组长度限制。自定义检查函数被禁用,访问器只描述而不求值。收集器在启动结算后停止,不会独立于已记录错误额外收集环境变量或配置内容。
+
+在 POSIX 上,新目录和文件分别请求 `0700` 和 `0600` 权限。CLI 仅在写入成功后输出文件路径。如果日志目录或文件无法写入,stderr 会包含写入错误及完整报告,退出码仍为 1。只有可选插件激活异常时,维持正常警告输出,不创建报告。
+
 ## 插件管理
 
 `dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。

+ 18 - 15
apps/cli/src/bin.ts

@@ -9,7 +9,9 @@
 import { readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
+import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { parseDshArgs } from './args.ts'
+import { reportStartupFailure } from './startup-diagnostics.ts'
 
 // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
 // one directory under apps/cli, so the checked-in manifest resolves with the
@@ -26,18 +28,25 @@ function readVersion(): string {
  * @returns a promise that settles when the selected command mode finishes.
  */
 export async function runCli(): Promise<void> {
-  const invocation = parseDshArgs(process.argv.slice(2), readVersion())
+  const version = readVersion()
+  const invocation = parseDshArgs(process.argv.slice(2), version)
 
   switch (invocation.mode) {
     case 'profile': {
       const { runProfile } = await import('./profile-boot.ts')
-      await runProfile({
-        environment: loadLayeredEnv('dsh'),
-        profile: invocation.profile,
-        fromDefaultProfile: invocation.fromDefaultProfile,
-        patchFiles: invocation.patches,
-        args: invocation.args,
-      })
+      try {
+        await runProfile({
+          environment: loadLayeredEnv('dsh'),
+          profile: invocation.profile,
+          fromDefaultProfile: invocation.fromDefaultProfile,
+          patchFiles: invocation.patches,
+          args: invocation.args,
+        })
+      } catch (error) {
+        if (!(error instanceof StartupError)) throw error
+        await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile })
+        process.exitCode = 1
+      }
       break
     }
     case 'plugin': {
@@ -62,11 +71,5 @@ export async function runCli(): Promise<void> {
 }
 
 if (import.meta.main) {
-  try {
-    await runCli()
-  } catch (error) {
-    if (!(error instanceof StartupError)) throw error
-    process.stderr.write(`${error.message}\n`)
-    process.exitCode = 1
-  }
+  await runCli()
 }

+ 58 - 0
apps/cli/src/startup-diagnostics.ts

@@ -0,0 +1,58 @@
+/** Save original startup diagnostics while keeping the terminal report concise. */
+
+import { randomUUID } from 'node:crypto'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { inspect } from 'node:util'
+import type { StartupError } from '@deepseek-ai/dsh-app-boot'
+
+/** Launcher-owned context; no environment values or plugin configurations are collected. */
+interface StartupDiagnosticContext {
+  home: string
+  version: string
+  profile: string
+}
+
+/**
+ * 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.
+ */
+export async function reportStartupFailure(
+  error: StartupError,
+  context: StartupDiagnosticContext,
+  write: (text: string) => void = text => void process.stderr.write(text),
+): Promise<void> {
+  const now = new Date().toISOString()
+  const report = inspect({
+    timestamp: now,
+    dshVersion: context.version,
+    nodeVersion: process.version,
+    platform: process.platform,
+    arch: process.arch,
+    profile: context.profile,
+    error,
+  }, {
+    depth: null,
+    maxArrayLength: null,
+    maxStringLength: null,
+    showHidden: true,
+    customInspect: false,
+    getters: false,
+    colors: false,
+  }) + '\n'
+  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}`)
+    return
+  }
+  write(`\nFull diagnostics: ${logPath}\n`)
+}

+ 26 - 4
apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts

@@ -3,7 +3,7 @@
 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 { dirname, join } from 'node:path'
 import type { Readable } from 'node:stream'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { execa } from 'execa'
@@ -247,10 +247,11 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
     }
   })
 
-  it('fails the full Web profile when its required HTTP server cannot bind', async () => {
+  it.each([false, true])('fails the full Web profile when its required HTTP server cannot bind (logs blocked: %s)', async (logsBlocked) => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
     const home = join(root, 'home')
     mkdirSync(home)
+    if (logsBlocked) writeFileSync(join(home, 'logs'), 'blocked')
     const blocker = createServer()
     await new Promise<void>((resolve, reject) => {
       const fail = (error: Error): void => { reject(error) }
@@ -297,8 +298,29 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
       expect(result.stderr).toContain('Plugins waiting for services (')
       expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u)
       expect(result.stderr).toContain('at Server.setupListenHandle')
-      expect(result.stderr.match(/EADDRINUSE/gu)).toHaveLength(1)
-      expect(result.stderr).not.toMatch(/dsh: warning:|\[cause\]|at boot \(|at runCli \(|Node\.js v/u)
+      const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]!
+      expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1)
+      expect(summary).not.toMatch(/dsh: warning:|\[cause\]|at boot \(|at runCli \(|Node\.js v/u)
+      let report: string
+      if (logsBlocked) {
+        expect(result.stderr).toContain('dsh: warning: could not write startup diagnostics:')
+        expect(result.stderr).not.toMatch(/Full diagnostics: [^\r\n]/u)
+        report = result.stderr.split('Full diagnostics:\n')[1]!
+        expect(readFileSync(join(home, 'logs'), 'utf8')).toBe('blocked')
+      } else {
+        const path = /Full diagnostics: ([^\r\n]+)/u.exec(result.stderr)?.[1]
+        expect(path).toBeDefined()
+        expect(dirname(path!)).toBe(join(home, 'logs'))
+        report = readFileSync(path!, 'utf8')
+      }
+      expect(report).toContain("profile: 'web'")
+      expect(report).toContain('nodeVersion:')
+      expect(report).toContain('dshVersion:')
+      expect(report).toContain('configurationPath:')
+      expect(report).toContain("code: 'EADDRINUSE'")
+      expect(report).toContain(`port: ${String(address.port)}`)
+      expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'")
+      expect(report).toContain('at auditStartupEntries')
     } finally {
       await new Promise<void>((resolve, reject) => {
         blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })

+ 103 - 0
apps/cli/tests/startup-diagnostics.spec.ts

@@ -0,0 +1,103 @@
+import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { inspect } from 'node:util'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { StartupError } from '@deepseek-ai/dsh-app-boot'
+import { reportStartupFailure } from '../src/startup-diagnostics.ts'
+
+async function home(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-startup-diagnostics-'))
+  onTestFinished(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+function startupError(reason: unknown): StartupError {
+  const error = new StartupError('dsh: startup failed: 1 required plugin did not activate', [
+    { id: 'webserver', module: './webserver.mjs', required: true, fiberState: 3, outcome: { kind: 'failed', error: reason } },
+    { id: 'waiting', module: './waiting.mjs', required: false, fiberState: 0, outcome: { kind: 'pending', missing: ['webServer'] } },
+  ])
+  error.startup = {
+    configurationPath: '/example/cordis.yml',
+    messages: [{ ts: 1, name: 'loader', type: 'error', args: [reason] }],
+  }
+  return error
+}
+
+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)
+    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')}`))
+  })
+
+  it('retains original error properties, causes, aggregate members, cycles, and long values', async () => {
+    const dir = await home()
+    const chunks: string[] = []
+    const large = 'x'.repeat(12_000) + 'END_OF_VALUE'
+    const leaf = Object.assign(new Error('cannot listen'), { code: 'EADDRINUSE', payload: large })
+    Object.defineProperty(leaf, 'hiddenDetail', { value: 'non-enumerable detail' })
+    const symbol = Symbol('diagnostic-field')
+    Object.assign(leaf, { [symbol]: 42n, self: leaf, values: Array.from({ length: 105 }, (_, i) => `value-${i}`) })
+    let inspected = false
+    let getterRead = false
+    Object.defineProperty(leaf, inspect.custom, { value: () => { inspected = true; return 'hidden by custom inspector' } })
+    Object.defineProperty(leaf, 'lazy', { get: () => { getterRead = true; return 'evaluated getter' } })
+    const aggregate = new AggregateError([leaf, { transport: 'closed' }], 'activation failed', { cause: leaf })
+    const error = startupError(aggregate)
+
+    await reportStartupFailure(error, { home: dir, version: '1.2.3', profile: 'web' }, (text) => { chunks.push(text) })
+
+    const files = await readdir(join(dir, 'logs'))
+    expect(files).toHaveLength(1)
+    expect(files[0]).toMatch(/^startup-[\w.-]+\.log$/u)
+    const path = join(dir, 'logs', files[0]!)
+    expect(chunks.join('')).toBe(`${error.message}\n\nFull diagnostics: ${path}\n`)
+    const report = await readFile(path, 'utf8')
+    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:',
+      'AggregateError: activation failed', '[cause]', '[errors]', 'EADDRINUSE', '[hiddenDetail]',
+      'non-enumerable detail', 'Symbol(diagnostic-field)', '42n', '[Circular', large, 'value-104', '[Getter]',
+      'transport:', 'closed', 'at startupError',
+    ]) expect(report).toContain(text)
+    expect(inspected).toBe(false)
+    expect(getterRead).toBe(false)
+    if (process.platform !== 'win32') {
+      expect((await stat(path)).mode & 0o777).toBe(0o600)
+      expect((await stat(join(dir, 'logs'))).mode & 0o777).toBe(0o700)
+    }
+  })
+
+  it('creates distinct files for concurrent failures without replacing earlier reports', async () => {
+    const dir = await home()
+    await Promise.all(['first', 'second'].map(reason => reportStartupFailure(
+      startupError(reason), { home: dir, version: '1.2.3', profile: 'web' }, () => {},
+    )))
+    const files = await readdir(join(dir, 'logs'))
+    expect(files).toHaveLength(2)
+    const reports = await Promise.all(files.map(file => readFile(join(dir, 'logs', file), 'utf8')))
+    expect(reports.filter(report => report.includes("error: 'first'"))).toHaveLength(1)
+    expect(reports.filter(report => report.includes("error: 'second'"))).toHaveLength(1)
+  })
+
+  it('prints the complete report when the logs directory cannot be created', async () => {
+    const dir = await home()
+    await writeFile(join(dir, 'logs'), 'blocked')
+    const chunks: string[] = []
+    await reportStartupFailure(startupError({ code: 'CUSTOM', value: 'original details' }), {
+      home: dir, version: '1.2.3', profile: 'web',
+    }, (text) => { chunks.push(text) })
+    const output = chunks.join('')
+    expect(output).toContain('dsh: startup failed:')
+    expect(output).toContain('dsh: warning: could not write startup diagnostics:')
+    expect(output).toContain('Full diagnostics:\n')
+    expect(output).toContain('original details')
+    expect(output).toContain('CUSTOM')
+    expect(output).not.toContain('Full diagnostics: ')
+    expect(await readFile(join(dir, 'logs'), 'utf8')).toBe('blocked')
+  })
+})

+ 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: 970b658cd56843762686dada06a68e017167f3d5
-README.zh.md: b5a42d0f668e500a1573e0df5e74dfa06e113bf4
+README.md: 7237f0cd176f4092407f8944703276999e7f3f0c
+README.zh.md: b87072bfeeac22268a7baa2ff5646dc737c9b02f

+ 3 - 1
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 exits 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. 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 |
 |---|---|---|---|
@@ -118,6 +118,8 @@ This section explains how the outcomes above are realized and points at the code
 - **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.** 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.
+
 ### Helper behavior
 
 The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts).

+ 3 - 1
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 仅输出该消息一次,并以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
+Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
 
 | 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR |
 |---|---|---|---|
@@ -118,6 +118,8 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
 
+启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。
+
 ### Helper 行为
 
 每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染、profile 组合,以及 harness 源码段落。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。

+ 49 - 6
packages/boot/app-boot/src/index.ts

@@ -724,8 +724,38 @@ interface InactiveEntry {
     | { kind: 'pending'; missing: string[] }
 }
 
-/** Startup audit failure whose message is a complete CLI diagnostic; cause retains plugin errors. */
-export class StartupError extends Error {}
+/** Inactive plugin metadata without retaining its Context or Fiber. */
+interface StartupEntryDiagnostic {
+  id: string
+  module: string
+  required: boolean
+  fiberState: FiberState | undefined
+  outcome: InactiveEntry['outcome']
+}
+
+/** Startup warning or error arguments, including import errors with no Fiber. */
+interface StartupLogRecord {
+  ts: number
+  name: string
+  type: string
+  args: readonly unknown[]
+}
+
+/** Startup audit failure with a concise message and original diagnostic data. */
+export class StartupError extends Error {
+  /** Root configuration and startup logs, attached by boot after disposal. */
+  startup?: { configurationPath: string; messages: readonly StartupLogRecord[] }
+
+  /**
+   * @param message - concise terminal diagnostic.
+   * @param entries - inactive plugin metadata and original failure values.
+   */
+  constructor(message: string, readonly entries: readonly StartupEntryDiagnostic[]) {
+    super(message, {
+      cause: new AggregateError(entries.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : []), 'Plugin activation failures'),
+    })
+  }
+}
 
 /**
  * Collect Loader activation failures and disabled-expression errors. Failed
@@ -798,6 +828,7 @@ function startupDiagnostic(binName: string, failures: readonly InactiveEntry[],
   const lines = [`${binName}: startup failed: ${String(required.size)} required ${required.size === 1 ? 'plugin' : 'plugins'} did not activate`]
   const failed = failures.flatMap(({ entry, outcome }) => outcome.kind === 'failed' ? [{ entry, outcome }] : [])
   const pending = failures.flatMap(({ entry, outcome }) => outcome.kind === 'pending' ? [{ entry, outcome }] : [])
+  pending.sort((left, right) => Number(required.has(right.entry)) - Number(required.has(left.entry)))
   const label = (entry: Entry): string => `${entry.options.id}${required.has(entry) ? ' (required)' : ''}`
   if (failed.length > 0) {
     lines.push('', `Failed plugins (${String(failed.length)}):`)
@@ -841,9 +872,9 @@ export async function auditStartupEntries(
   const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx)
     || requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
   if (required.size > 0) {
-    throw new StartupError(startupDiagnostic(binName, failures, required), {
-      cause: new AggregateError(failures.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : []), 'Plugin activation failures'),
-    })
+    throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
+      id: entry.options.id, module: entry.options.name, required: required.has(entry), fiberState: entry.fiber?.state, outcome,
+    })))
   }
   if (failures.length > 0) warn(activationDiagnostic(binName, failures))
 }
@@ -887,6 +918,13 @@ export async function boot(
   bareModuleBaseUrl?: string,
 ): Promise<Context> {
   const ctx = new Context()
+  const startupLogs: StartupLogRecord[] = []
+  const stopStartupLogs = ctx.logger.exporter({
+    levels: { default: 2 },
+    export: ({ ts, name, type, args }) => {
+      if (type === 'warn' || type === 'error') startupLogs.push({ ts, name, type, args })
+    },
+  })
   // Two failure labels: `prepare` runs before any config-tree entry mounts,
   // so its failure is host setup, not the plugin tree.
   let stage = 'host preparation failed'
@@ -916,7 +954,10 @@ export async function boot(
     // fiber.ts hardening) and a repeated call returns the settled single-shot
     // result, so this await cannot reject and replace `cause`.
     await ctx.fiber.dispose()
-    if (cause instanceof StartupError) throw cause
+    if (cause instanceof StartupError) {
+      cause.startup = { configurationPath: absoluteConfigPath, messages: startupLogs }
+      throw cause
+    }
     const detail = cause instanceof Error ? cause.message : String(cause)
     // A wrapper can carry an activation error whose original stack names the failed plugin.
     let deepest: unknown = cause
@@ -929,6 +970,8 @@ export async function boot(
       ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
       : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
     throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
+  } finally {
+    await stopStartupLogs()
   }
 }
 

+ 33 - 1
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -723,8 +723,8 @@ describe('auditStartupEntries', () => {
 
       Plugins waiting for services (3):
         Plugin                 Missing services
-        web-runtime            webServer
         connection (required)  webRuntime
+        web-runtime            webServer
         unknown                unknown"
     `)
     expect(warn).not.toHaveBeenCalled()
@@ -760,6 +760,38 @@ describe('loadOverlayPatches', () => {
 })
 
 describe('boot', () => {
+  it('retains import errors and inactive-entry metadata after disposing the startup tree', async () => {
+    const dir = tmp()
+    const config = join(dir, 'cordis.yml')
+    writeFileSync(config, '- id: webserver\n  name: ./missing.mjs\n')
+    const failure = await boot(NAME, config).catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(StartupError)
+    const error = failure as StartupError
+    expect(error.entries).toEqual([{
+      id: 'webserver', module: './missing.mjs', required: true, fiberState: undefined,
+      outcome: { kind: 'failed', error: 'failed to import' },
+    }])
+    expect(error.startup?.configurationPath).toBe(config)
+    expect(error.startup?.messages.some(message => message.args.some(arg => arg instanceof Error && arg.message.includes('missing.mjs')))).toBe(true)
+  })
+
+  it('stops collecting startup diagnostics after a successful boot', async () => {
+    const dir = tmp()
+    const config = join(dir, 'cordis.yml')
+    writeFileSync(config, '[]\n')
+    let exporters = 0
+    const ctx = await boot(NAME, config, undefined, (host) => {
+      exporters = host.logger.exporters.size
+      host.logger.info('startup information')
+      host.logger.warn('startup warning')
+    })
+    try {
+      expect(ctx.logger.exporters.size).toBe(exporters - 1)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('boots a leaf config through the real Loader and settles the tree', async () => {
     const dir = tmp()
     writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')