瀏覽代碼

Merge pull request #4006 from deepseek-harness/turtle/boot-failure-e2e

test(boot): cover Web failure recovery and inject module routes
Turtle 1 周之前
父節點
當前提交
16f80298df

+ 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: 8e3d5f0141245ba2fa2c85faba607ac1be952e37
-2026-09-09-consumer-owned-startup-strictness.zh.md: 3873b9ceeb6204939e817a53514f5e18767ececc
+2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2
+2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4

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

@@ -33,3 +33,7 @@ Stable required entry ids are part of application assembly. Renaming one require
 ## Testing
 
 App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
+
+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.
+
+The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing.

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

@@ -33,3 +33,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
 ## 测试
 
 App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
+
+[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)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
+
+矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。

+ 2 - 2
apps/cli/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/README.md
-README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212
-README.zh.md: 553f28f3af78b18658701449678de80b086dbb15
+README.md: cfab7a4983c31a21b68157c9cefb38c1654571ff
+README.zh.md: 67d07fba1fc27bca9c2a696daddde88aac3aa39c

+ 4 - 2
apps/cli/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
+The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, and fatal configuration or boot failures exit nonzero.
 
 ## Entry modes
 
@@ -45,7 +45,7 @@ Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (
 
 Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
 
-The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
+The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR.
 
 ## Optional overlays
 
@@ -54,3 +54,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
 ## Development
 
 Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
+
+The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
+`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项,以及致命的配置或启动错误都会以非零状态退出。
 
 ## 入口模式
 
@@ -45,7 +45,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 
 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
 
-层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。
+层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。
 
 ## 可选覆盖层
 
@@ -54,3 +54,5 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 ## 开发
 
 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
+
+[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。

+ 404 - 0
apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts

@@ -0,0 +1,404 @@
+/** Failure policy through the built Web process and native configuration watcher. */
+
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { createServer } from 'node:http'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+import { FiberState } from '@deepseek-ai/cordis'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+const bin = join(repoRoot, 'apps/cli/lib/bin.js')
+const built = existsSync(bin) && existsSync(join(repoRoot, 'apps/web/dist/index.html'))
+const failures = [
+  ['import', 'missing.mjs'],
+  ['module evaluation', 'matrix module evaluation'],
+  ['schema', 'matrix schema failure'],
+  ['config expression', 'matrix config expression'],
+  ['disabled expression', 'matrix disabled expression'],
+  ['sync apply', 'matrix sync apply'],
+  ['async apply', 'matrix async apply'],
+  ['dependency', 'matrixMissingService'],
+] as const
+type Failure = typeof failures[number][0]
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-web-failure-matrix-'))
+  const home = join(root, 'home')
+  mkdirSync(home)
+  const events = join(root, 'events')
+  const diagnostics = join(root, 'diagnostics')
+  const serverUrl = join(root, 'server-url')
+  const states = join(root, 'states')
+  const stop = join(root, 'stop')
+  const patch = join(home, 'cordis.patch.yml')
+  const watcher = join(root, 'watcher.patch.yml')
+  // Native delivery remains real; completed writes bypass Chokidar's 50 ms change suppression.
+  writeFileSync(watcher, JSON.stringify([{ id: 'hmr', disabled: false, config: {
+    root: [], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
+  } }]) + '\n')
+  writeFileSync(events, '')
+  writeFileSync(diagnostics, '')
+  writeFileSync(states, '{}')
+  const observerPath = join(root, 'observer.mjs')
+  // HMR reports through Cordis logger exporters; WARN is above their default INFO threshold.
+  writeFileSync(observerPath, [
+    "import { appendFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'",
+    "import { inspect } from 'node:util'",
+    'export function apply(ctx, config) {',
+    '  const report = message => appendFileSync(config.path, message.args.map(arg => inspect(arg)).join(" ") + "\\n")',
+    '  ctx.logger.buffer.forEach(report)',
+    '  ctx.logger.exporter({ levels: { default: 3 }, export: report })',
+    '  const timer = setInterval(() => {',
+    '    const loader = ctx.get("loader")',
+    '    if (!loader) return',
+    '    writeFileSync(config.states + ".tmp", JSON.stringify(Object.fromEntries([...loader.entries()].map(entry => [entry.options.id, entry.fiber?.state]))))',
+    '    renameSync(config.states + ".tmp", config.states)',
+    '  }, 20)',
+    '  ctx.effect(() => () => { clearInterval(timer) })',
+    '  ctx.inject(["webServer", "connection"], scope => {',
+    '    writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
+    '    scope.effect(() => () => { rmSync(config.url, { force: true }) })',
+    '  })',
+    '}',
+    '',
+  ].join('\n'))
+  const observer = { id: 'matrix-log-observer', name: pathToFileURL(observerPath).href, config: { path: diagnostics, url: serverUrl, states } }
+  const plugin = join(root, 'probe.mjs')
+  writeFileSync(plugin, [
+    "import { appendFileSync, existsSync } from 'node:fs'",
+    'export const Config = { "~standard": { version: 1, vendor: "matrix", validate(value) {',
+    '  return value.mode === "schema" ? { issues: [{ message: "matrix schema failure" }] } : { value }',
+    '} } }',
+    'export const apply = (ctx, config) => {',
+    '  if (config.mode === "sync apply") throw new Error("matrix sync apply")',
+    '  return activate(ctx, config)',
+    '}',
+    'async function activate(ctx, config) {',
+    '  if (config.mode === "async apply") { await Promise.resolve(); throw new Error("matrix async apply") }',
+    '  if (config.provider) ctx.provide(config.provider, true)',
+    '  if (config.mode === "detached") setImmediate(() => { void Promise.reject(new Error("matrix detached failure")) })',
+    '  appendFileSync(config.events, `${config.label} apply ${config.generation}\\n`)',
+    '  const timer = setInterval(() => { if (existsSync(config.stop)) process.emit("SIGTERM") }, 20)',
+    '  ctx.effect(() => () => { clearInterval(timer); appendFileSync(config.events, `${config.label} dispose ${config.generation}\\n`) })',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'evaluation.mjs'), 'throw new Error("matrix module evaluation")\n')
+  const url = pathToFileURL(plugin).href
+  const config = (label: string, generation: number, mode = '') => ({ label, generation, mode, events, stop })
+  const witness = (generation: number) => ({ id: 'matrix-witness', name: url, config: config('witness', generation) })
+  const target = (id: string, failure?: Failure, generation = 1) => ({
+    id,
+    name: failure === 'import' ? pathToFileURL(join(root, 'missing.mjs')).href
+      : failure === 'module evaluation' ? pathToFileURL(join(root, 'evaluation.mjs')).href : url,
+    ...(failure === 'dependency' ? { inject: ['matrixMissingService'] } : {}),
+    config: config('target', generation, failure),
+  })
+  const render = (id: string, failure?: Failure, generation = 1) => {
+    let text = `- insert: ${JSON.stringify([observer, witness(generation), target(id, failure, generation)])}`
+    if (failure === 'config expression') text += `\n- id: ${id}\n  config: !!js "(() => { throw new Error('matrix config expression') })()"\n`
+    if (failure === 'disabled expression') text += `\n- id: ${id}\n  disabled: !!js "(() => { throw new Error('matrix disabled expression') })()"\n`
+    return text + '\n'
+  }
+  writeFileSync(patch, JSON.stringify([{ insert: [observer, witness(0)] }]) + '\n')
+  return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
+}
+
+function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
+  const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
+    cwd: f.root,
+    env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
+    input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
+  })
+  let stdout = ''
+  let stderr = ''
+  child.stdout?.setEncoding('utf8').on('data', (text: string) => { stdout += text })
+  child.stderr?.setEncoding('utf8').on('data', (text: string) => { stderr += text })
+  let exited = false
+  void child.then(() => { exited = true })
+  async function wait(predicate: () => boolean) {
+    try {
+      await expect.poll(() => {
+        if (exited) throw new Error('Web process exited')
+        return predicate()
+      }, { timeout: 45_000 }).toBe(true)
+    } catch (cause) { throw new Error(`Web condition failed\n${stdout}\n${stderr}\n${readFileSync(f.diagnostics, 'utf8')}\n${readFileSync(f.events, 'utf8')}`, { cause }) }
+  }
+  async function serves(currentServer = false) {
+    await wait(() => /dsh web: http:\/\//u.test(stdout))
+    const url = currentServer ? readFileSync(f.serverUrl, 'utf8') : /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
+    if (!url) throw new Error('Missing Web URL')
+    const auth = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10_000) })
+    const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
+    if (!cookie) throw new Error('Missing Web authentication cookie')
+    const response = await fetch(new URL('/', url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
+    expect(response.status).toBe(200)
+    const html = await response.text()
+    expect(html).toContain('__DSH_BOOT__')
+    const bundlePath = /<script src="(\/plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&amp;', '&')
+    if (!bundlePath) throw new Error('Missing bootstrap bundle URL')
+    const bundle = await fetch(new URL(bundlePath, url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
+    expect(bundle.status).toBe(200)
+    expect(await bundle.text()).not.toBe('')
+  }
+  async function close() {
+    writeFileSync(f.stop, 'stop')
+    // Assertions may fail before a probe applies; forceful teardown still awaits exit.
+    const timer = setTimeout(() => child.kill('SIGKILL'), 10_000)
+    try {
+      const result = await child
+      return { timedOut: result.timedOut, signal: result.signal, exitCode: result.exitCode, stderr: result.stderr, events: readFileSync(f.events, 'utf8') }
+    } finally {
+      clearTimeout(timer)
+      rmSync(f.root, { recursive: true, force: true })
+    }
+  }
+  const state = (id: string) => (JSON.parse(readFileSync(f.states, 'utf8')) as Record<string, number | undefined>)[id]
+  return { child, wait, serves, close, state, stderr: () => stderr, logs: () => readFileSync(f.diagnostics, 'utf8'), events: () => readFileSync(f.events, 'utf8') }
+}
+
+function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode?: number | undefined; stderr: string }, code: number) {
+  expect(result.timedOut, result.stderr).toBe(false)
+  expect(result.signal, result.stderr).toBeUndefined()
+  expect(result.exitCode, result.stderr).toBe(code)
+}
+
+describe.skipIf(!built)('Web process failure matrix', () => {
+  for (const required of [false, true]) {
+    const id = required ? 'acp' : 'matrix-optional'
+    it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
+      const f = fixture()
+      writeFileSync(f.patch, f.render(id, failure))
+      const app = start(f)
+      try {
+        if (required) {
+          const result = await app.child
+          exit(result, 1)
+          expect(result.stdout).not.toContain('dsh web: http://')
+          expect(result.stderr).toContain('required startup failure')
+          expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n')
+        } else {
+          await app.serves()
+          await app.wait(() => (app.stderr() + app.logs()).includes(diagnostic))
+          expect(app.stderr()).toContain('warning: 1 entry did not activate')
+          expect(app.events()).toBe('witness apply 1\n')
+        }
+        expect(app.stderr() + app.logs()).toContain(diagnostic)
+      } finally {
+        const result = await app.close()
+        exit(result, required ? 1 : 0)
+        expect(result.events).toContain('witness dispose 1\n')
+      }
+    })
+
+    it.each(failures)(`${required ? 'required' : 'optional'} native HMR %s keeps siblings and recovers`, async (failure, diagnostic) => {
+      const f = fixture()
+      const app = start(f)
+      try {
+        await app.serves()
+        writeFileSync(f.patch, f.render(id, failure))
+        await app.wait(() => app.events().includes('witness apply 1\n') && (failure === 'dependency' ? app.state(id) === FiberState.PENDING : app.logs().includes(diagnostic)))
+        expect(app.events()).not.toContain('witness dispose 1\n')
+        expect(app.events()).not.toContain('target apply')
+        expect(readFileSync(f.patch, 'utf8')).toBe(f.render(id, failure))
+        await app.serves()
+        if (failure === 'dependency') {
+          writeFileSync(f.patch, JSON.stringify([
+            { insert: [f.observer, f.witness(1), f.target(id, failure)] },
+            { insert: [{ id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 2), provider: 'matrixMissingService' } }] },
+          ]) + '\n')
+        } else writeFileSync(f.patch, f.render(id, undefined, 2))
+        await app.wait(() => app.events().includes(`target apply ${failure === 'dependency' ? 1 : 2}\n`))
+        await app.serves()
+        expect(app.stderr()).not.toContain('required startup failure')
+      } finally {
+        const result = await app.close()
+        exit(result, 0)
+        expect(result.events).toContain(`witness dispose ${failure === 'dependency' ? 1 : 2}\n`)
+        expect(result.events).toContain(`target dispose ${failure === 'dependency' ? 1 : 2}\n`)
+      }
+    })
+  }
+
+  it.each([
+    ['missing', undefined, 'failed to read overlay'],
+    ['unreadable directory', undefined, 'failed to read overlay'],
+    ['malformed', 'invalid: [unclosed\n', 'failed to parse overlay'],
+    ['non-array', 'entries: []\n', 'top-level YAML array'],
+    ['non-mapping', '- null\n', 'must be a mapping'],
+  ])('rejects a %s explicit overlay before readiness', async (_kind, content, diagnostic) => {
+    const f = fixture()
+    const overlay = join(f.root, 'invalid.patch.yml')
+    if (_kind === 'unreadable directory') mkdirSync(overlay)
+    else if (content !== undefined) writeFileSync(overlay, content)
+    const app = start(f, ['--patch', overlay])
+    try {
+      const result = await app.child
+      exit(result, 1)
+      expect(result.stderr).toContain(diagnostic)
+      expect(result.stdout).not.toContain('dsh web: http://')
+      expect(app.events()).toBe('')
+    } finally { exit(await app.close(), 1) }
+  })
+
+  it.each([
+    ['malformed', 'invalid: [unclosed\n', 'failed to parse patches'],
+    ['non-array', 'entries: []\n', 'top-level YAML array'],
+    ['non-mapping', '- null\n', 'must be a mapping'],
+  ])('native HMR rejects %s patches, preserves the app and accepts a correction', async (_kind, content, diagnostic) => {
+    const f = fixture()
+    const app = start(f)
+    try {
+      await app.serves()
+      writeFileSync(f.patch, content)
+      await app.wait(() => app.logs().includes(diagnostic))
+      expect(app.events()).toBe('witness apply 0\n')
+      await app.serves()
+      writeFileSync(f.patch, f.render('matrix-optional'))
+      await app.wait(() => app.events().includes('target apply 1\n'))
+      await app.serves()
+    } finally { exit(await app.close(), 0) }
+  })
+
+  it('native HMR schema failure retains the existing config until a valid correction', async () => {
+    const f = fixture()
+    writeFileSync(f.patch, f.render('acp', undefined, 1))
+    const app = start(f)
+    try {
+      await app.serves()
+      writeFileSync(f.patch, f.render('acp', 'schema', 2))
+      await app.wait(() => app.logs().includes('matrix schema failure') && app.events().includes('witness apply 2\n'))
+      expect(app.events()).toContain('target apply 1\n')
+      expect(app.events()).not.toContain('target dispose 1\n')
+      expect(app.events()).not.toContain('target apply 2\n')
+      await app.serves()
+      writeFileSync(f.patch, f.render('acp', undefined, 3))
+      await app.wait(() => app.events().includes('target apply 3\n'))
+      expect(app.events()).toContain('target dispose 1\n')
+      await app.serves()
+    } finally { exit(await app.close(), 0) }
+  })
+
+  it('ignores absent and explicitly disabled required entries at startup', async () => {
+    const f = fixture()
+    writeFileSync(f.patch, f.render('acp', 'import') + '- id: acp\n  disabled: true\n')
+    const app = start(f)
+    try {
+      await app.serves()
+      expect(app.stderr()).not.toContain('required startup failure')
+      expect(app.stderr()).not.toContain('failed to import')
+    } finally { exit(await app.close(), 0) }
+  })
+
+  it('detached rejection from a hot-loaded plugin terminates and disposes the app', async () => {
+    const f = fixture()
+    const app = start(f)
+    try {
+      await app.serves()
+      writeFileSync(f.patch, JSON.stringify([{ insert: [f.observer, f.witness(0), {
+        id: 'matrix-detached', name: f.url, config: f.config('detached', 1, 'detached'),
+      }] }]) + '\n')
+      const result = await app.child
+      exit(result, 1)
+      expect(result.stderr).toContain('fatal load failure: Error: matrix detached failure')
+      expect(app.events()).toContain('witness dispose 0\n')
+      expect(app.events()).toContain('detached dispose 1\n')
+    } finally { exit(await app.close(), 1) }
+  })
+
+  it('native HMR reports a required Web server bind failure without terminating the process', async () => {
+    const f = fixture()
+    const blocker = createServer()
+    const app = start(f)
+    try {
+      await new Promise<void>((resolve, reject) => {
+        blocker.once('error', reject)
+        blocker.listen(0, '127.0.0.1', resolve)
+      })
+      const address = blocker.address()
+      if (!address || typeof address === 'string') throw new Error('Missing blocker address')
+      await app.serves()
+      writeFileSync(f.patch, f.render('matrix-optional') + `- id: webserver\n  config:\n    host: 127.0.0.1\n    port: ${address.port}\n`)
+      await app.wait(() => app.logs().includes('EADDRINUSE') && app.events().includes('witness apply 1\n'))
+      expect(app.stderr()).not.toContain('required startup failure')
+      expect(app.events()).not.toContain('witness dispose 1\n')
+      expect(existsSync(f.serverUrl)).toBe(false)
+      writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
+      await app.wait(() => app.events().includes('witness apply 2\n') && existsSync(f.serverUrl))
+      await app.serves(true)
+    } finally {
+      try { exit(await app.close(), 0) } finally {
+        await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
+      }
+    }
+  })
+
+  it.each(['startup', 'HMR'])('optional HTTP bind failure at %s leaves Web serving', async (phase) => {
+    const f = fixture()
+    const blocker = createServer()
+    let app: ReturnType<typeof start> | undefined
+    try {
+      const plugin = join(f.root, 'http.mjs')
+      writeFileSync(plugin, [
+        'import { createServer } from "node:http"',
+        'export async function apply(ctx, config) {',
+        '  const server = createServer()',
+        '  ctx.effect(() => () => new Promise(resolve => server.close(() => resolve())))',
+        '  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(config.port, "127.0.0.1", resolve) })',
+        '}',
+        '',
+      ].join('\n'))
+      await new Promise<void>((resolve, reject) => {
+        blocker.once('error', reject)
+        blocker.listen(0, '127.0.0.1', resolve)
+      })
+      const address = blocker.address()
+      if (!address || typeof address === 'string') throw new Error('Missing blocker address')
+      const patch = JSON.stringify([{ insert: [f.observer, f.witness(0), {
+        id: 'matrix-optional-http', name: pathToFileURL(plugin).href, config: { port: address.port },
+      }] }]) + '\n'
+      if (phase === 'startup') writeFileSync(f.patch, patch)
+      app = start(f)
+      const running = app
+      await app.serves()
+      if (phase === 'HMR') writeFileSync(f.patch, patch)
+      await app.wait(() => (running.logs() + running.stderr()).includes('EADDRINUSE'))
+      expect(app.events()).toBe('witness apply 0\n')
+      await app.serves()
+    } finally {
+      try {
+        if (app) exit(await app.close(), 0)
+        else rmSync(f.root, { recursive: true, force: true })
+      } finally {
+        if (blocker.listening) {
+          await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
+        }
+      }
+    }
+  })
+
+  it.each(['modules', 'connection'])('native HMR recovers the shipped required %s entry', async (id) => {
+    const f = fixture()
+    const app = start(f)
+    try {
+      await app.serves()
+      // Entry-level injection requirements are captured when the fiber is created.
+      writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n  disabled: true\n`)
+      await app.wait(() => app.state(id) === FiberState.DISPOSED)
+      const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
+      const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n  inject: ${JSON.stringify(inject)}\n`
+      writeFileSync(f.patch, pending)
+      await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
+      expect(app.events()).not.toContain('witness dispose 2\n')
+      expect(app.stderr()).not.toContain('required startup failure')
+      writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{
+        id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' },
+      }])}\n`)
+      await app.wait(() => app.state(id) === FiberState.ACTIVE && existsSync(f.serverUrl))
+      await app.serves(true)
+    } finally { exit(await app.close(), 0) }
+  })
+})

+ 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: 88679e9d4bc53f36d49e8c5b19dcd724ebf573e8
-README.zh.md: 3336ac4e0c56c92e88525af80e6a88cd44b0c081
+README.md: 10d582289707c87bd84b0eb32267ac4f5addf884
+README.zh.md: c8bb61e28e8ad758c757fe11a5b55135e1daf319

+ 21 - 14
packages/boot/app-boot/README.md

@@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home:
 - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
 - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
 
-Profiles with `patchReload: live` watch both user patch files. Parse failures preserve the running configuration; plugin activation failures are reported and can leave a partially applied tree. A later valid edit can recover it. Loader changes are not rolled back. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
+Profiles with `patchReload: live` watch both user patch files and apply the [reload failure policy](#startup-and-reload-failures). A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
@@ -62,21 +62,28 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
 
-### What you see when startup fails
+<a id="startup-and-reload-failures"></a>
+### Startup and reload failures
 
-After the Loader settles, app-boot classifies each enabled entry by stable id. Optional failures produce one warning and leave active siblings running. Required failures produce the same entry detail, then dispose the application and reject startup.
+After the Loader settles, app-boot reports optional failures as warnings and rejects startup if an enabled required entry cannot activate. 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 | Entry result | Startup action |
-|---|---|---|
-| The root YAML cannot be read or parsed, or is not an entry list | Bootstrap Include fails | Reject and dispose; no partial application is accepted |
-| A plugin module cannot be imported | Entry has no fiber | Warn if optional; reject and dispose if required |
-| An entry's `disabled: !!js` expression throws | Entry cannot determine its disabled state; report the evaluation error | Warn if optional; reject and dispose if required |
-| Config expression evaluation or the plugin's config schema fails during activation | Fiber is `FAILED` with the validation error | Warn if optional; reject and dispose if required |
-| Synchronous `apply()` throws | Fiber is `FAILED` with the thrown error | Warn if optional; reject and dispose if required |
-| Asynchronous `apply()` throws | Fiber is `FAILED` with the thrown error | Warn if optional; reject and dispose if required |
-| Required injected services never appear | Fiber remains `PENDING` and names the missing services | Warn if optional; reject and dispose if required |
+| Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR |
+|---|---|---|---|
+| Root config or required overlay is missing, unreadable, malformed, or contains invalid entries | Stop startup | Stop startup | Malformed or invalid live patches are rejected without changing the running configuration; a valid edit applies |
+| Module import fails or module evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a corrected import can activate |
+| Plugin config schema validation fails | Warn; continue | Stop startup | A new entry stays inactive; an existing entry retains its prior instance and config; a valid correction applies |
+| Config `!!js` evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a valid correction can activate |
+| `disabled: !!js` evaluation throws | Warn; continue | Stop startup | Report the evaluation error rather than treating the entry as disabled; a valid correction can activate |
+| Synchronous `apply()` throws | Warn; continue | Stop startup | Report the error; keep successful siblings; corrected config can activate |
+| Asynchronous `apply()` throws | Warn after settlement; continue | Stop startup after settlement | Report the error after settlement; keep successful siblings; corrected config can activate |
+| An injected service is unavailable | Warn; continue while the entry waits for its dependencies | Stop startup | Keep the entry waiting; adding the missing provider can activate it |
+| HTTP port binding fails | Warn; continue without that endpoint | Stop startup | Keep the process running without the failed endpoint; corrected config can restore it |
+| Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id |
+| Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit |
 
-App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. Unrelated unhandled rejections remain fatal. Later config HMR reports failures without repeating the required-startup policy or restoring previous plugin config; a valid edit can recover the failed entry.
+The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes.
+
+The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) and [startup acceptance](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) verify these outcomes through the shipped Web profile; [app-boot tests](tests/app-boot.spec.ts) also exercise root Include failures.
 
 If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it.
 
@@ -98,7 +105,7 @@ This section explains how the outcomes above are realized and points at the code
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup.
+- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
 - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. Cyclic causes terminate diagnostic traversal without replacing the original cause.

+ 21 - 14
packages/boot/app-boot/README.zh.md

@@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
-带 `patchReload: live` 的 profile 会监视两份用户 patch 文件。解析失败会保留运行中的配置;插件激活失败会被报告,并可能留下部分应用的配置树。后续有效编辑可以恢复它。Loader 更改不会回滚。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
+带 `patchReload: live` 的 profile 会监视两份用户 patch 文件,并应用[重载失败策略](#startup-and-reload-failures)。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
@@ -62,21 +62,28 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
 
-### 启动失败时你会看到什么
+<a id="startup-and-reload-failures"></a>
+### 启动与重载失败
 
-Loader 结算后,app-boot 按稳定 id 对每个已启用 entry 分类。Optional failure 输出一次警告,并让 active sibling 继续运行。Required failure 输出相同的 entry 详情,然后拆卸应用并拒绝启动
+Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的 required 条目无法激活,则拒绝启动。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新
 
-| 失败模式 | Entry 结果 | 启动措施 |
-|---|---|---|
-| 根 YAML 无法读取或解析,或不是 entry list | Bootstrap Include 失败 | 拒绝并拆卸;不接受部分应用 |
-| Plugin module 无法 import | Entry 没有 fiber | Optional 时警告;required 时拒绝并拆卸 |
-| Entry 的 `disabled: !!js` 表达式抛出异常 | Entry 无法确定禁用状态;报告求值错误 | Optional 时警告;required 时拒绝并拆卸 |
-| Config expression 求值或 plugin config schema 在 activation 时失败 | Fiber 为 `FAILED`,保留校验错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 同步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 异步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 必需的 injected service 始终未出现 | Fiber 保持 `PENDING`,并指出缺失 service | Optional 时警告;required 时拒绝并拆卸 |
+| 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR |
+|---|---|---|---|
+| 根配置或必需 overlay 缺失、不可读、格式错误,或包含无效条目 | 终止启动 | 终止启动 | 拒绝格式错误或无效的实时 patch,不改变运行中的配置;有效修改可以应用 |
+| 模块 import 失败或模块求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正 import 后可以激活 |
+| 插件配置 schema 校验失败 | 警告;继续 | 终止启动 | 新条目保持未激活;现有条目保留原实例与配置;有效修正可以应用 |
+| 配置 `!!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;有效修正后可以激活 |
+| `disabled: !!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告求值错误,不将条目当作已禁用;有效修正后可以激活 |
+| 同步 `apply()` throw | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正配置后可以激活 |
+| 异步 `apply()` throw | 结算后警告;继续 | 结算后终止启动 | 结算后报告错误;保留成功的兄弟插件;修正配置后可以激活 |
+| 注入的服务不可用 | 警告;继续,条目等待依赖 | 终止启动 | 条目继续等待;补上缺失的提供方后可以激活 |
+| HTTP 端口绑定失败 | 警告;继续,但该端点不可用 | 终止启动 | 进程继续运行,但失败的端点不可用;修正配置后可以恢复 |
+| 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
+| 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 |
 
-App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。无关的未处理 rejection 仍然致命。之后的 config HMR 会报告失败,但不会再次应用 required 启动策略,也不会恢复旧 plugin config;有效修改可以恢复失败的 entry。
+上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。
+
+[Web 进程矩阵](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)和[启动验收测试](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)通过随附 Web profile 验证这些结果;[app-boot 测试](tests/app-boot.spec.ts)还覆盖根 Include 失败。
 
 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。
 
@@ -98,7 +105,7 @@ App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。
+- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因。

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: 83ba5357cf84a12105518ed057fd120584b636cb
-README.zh.md: 9e5c75838e52fda7d74b074b9c1a9dacef7932a8
+README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f
+README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c

+ 2 - 0
packages/client/modules/README.md

@@ -71,6 +71,8 @@ The Node half snapshots each client bundle and available source map before publi
 
 ### Boot manifest injection
 
+The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server.
+
 The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode.
 
 ### Source map

+ 2 - 0
packages/client/modules/README.zh.md

@@ -71,6 +71,8 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map
 
 ### 启动 manifest 注入
 
+bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。
+
 宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。
 
 ### 源码索引

+ 2 - 2
packages/client/modules/src/index.ts

@@ -504,6 +504,7 @@ export class ClientModuleRegistry extends Service {
 
   /**
    * Build the service: subscribe, seed, and run the activation flush.
+   * Bundle routes follow the optional Web carrier's injected lifecycle.
    * @param ctx - plugin context carrying Loader and an optional Web carrier.
    */
   constructor(ctx: Context) {
@@ -540,8 +541,7 @@ export class ClientModuleRegistry extends Service {
         'client-modules: bundle route',
       )
     }
-    if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier)
-    else registerWebCarrier(ctx)
+    ctx.inject(['webServer'], registerWebCarrier)
     ctx.on('webserver/index-inject', (table) => {
       table.push(...bootInjections(this.composed))
     })

+ 49 - 9
packages/client/modules/tests/node-half.client.spec.ts

@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { runInNewContext } from 'node:vm'
-import { Context, type Fiber } from '@deepseek-ai/cordis'
+import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
 import * as modulesClient from '../src/client/index.ts'
@@ -24,12 +24,50 @@ const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
 const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
 
 let root: string | undefined
+const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
 
-afterEach(() => {
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
+    await ready
+    await ctx.fiber.dispose()
+  }))
   if (root !== undefined) rmSync(root, { recursive: true, force: true })
   root = undefined
 })
 
+it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
+  const ctx = new Context()
+  contexts.push({ ctx })
+  ctx.provide('loader', { entries: () => [] })
+  const routes = new Set<WebRoute>()
+  const mountServer = () => ctx.plugin((serverCtx) => {
+    serverCtx.provide('webServer', {
+      register: (route: WebRoute) => {
+        routes.add(route)
+        return () => { routes.delete(route) }
+      },
+    } as WebServer)
+  })
+  let server = serverFirst ? await mountServer() : undefined
+  const modules = await ctx.plugin(ClientModuleRegistry)
+  const service = ctx.get('clientModules')!
+  expect(service.graph().entries).toEqual([])
+  expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
+  if (!serverFirst) {
+    expect(routes.size).toBe(0)
+    server = await mountServer()
+  }
+  await expect.poll(() => routes.size).toBe(1)
+  await server!.dispose()
+  await expect.poll(() => routes.size).toBe(0)
+  expect(modules.state).toBe(FiberState.ACTIVE)
+  expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
+  await mountServer()
+  await expect.poll(() => routes.size).toBe(1)
+  await modules.dispose()
+  expect(routes.size).toBe(0)
+})
+
 /** Create a resolvable package whose client export points at the returned path. */
 function writePackage(
   packageName: string,
@@ -65,8 +103,10 @@ function constructWithRoute(
     entryBaseUrl?: string
     internal?: NonNullable<Context['loader']['internal']>
   } = {},
-): { context: Context; service: ClientModuleRegistry; route: WebRoute } {
+): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
   const ctx = new Context()
+  const owned: typeof contexts[number] = { ctx }
+  contexts.push(owned)
   ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
   ctx.provide('loader', {
     internal: options.internal,
@@ -81,19 +121,19 @@ function constructWithRoute(
       }
     },
   })
-  let route: WebRoute | undefined
+  const route = Promise.withResolvers<WebRoute>()
   const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
     port: 0,
     register: (candidate) => {
-      if (candidate.path === '/plugins') route = candidate
+      if (candidate.path === '/plugins') route.resolve(candidate)
       return () => {}
     },
     tapIndex: () => () => {},
   }
   ctx.provide('webServer', webServer as WebServer)
   const service = new ClientModuleRegistry(ctx)
-  if (route === undefined) throw new Error('client bundle route was not registered')
-  return { context: ctx, service, route }
+  owned.ready = route.promise
+  return { context: ctx, service, route: route.promise }
 }
 
 /** Construct the node-half service over the enabled fixture entries. */
@@ -102,7 +142,7 @@ function construct(packageNames: string[]): ClientModuleRegistry {
 }
 
 /** Invoke the registered plugin route and capture status, headers, and bytes. */
-async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
+async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
   status: number
   headers: Record<string, string> | undefined
   body: Buffer
@@ -121,7 +161,7 @@ async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promi
       return response
     },
   } as unknown as ServerResponse
-  await route.handler({ method, url } as IncomingMessage, response)
+  await (await route).handler({ method, url } as IncomingMessage, response)
   return { status, headers, body }
 }