Przeglądaj źródła

feat(boot): distinguish required startup failures

Turtle 1 miesiąc temu
rodzic
commit
bd4cfc7c46

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: b9ffe13901c2adabfe95f8ae3f44d8eba83b6d01
+2026-09-09-consumer-owned-startup-strictness.zh.md: f6aae3922407d04c472b3fde57ce7a4f4f1fb888

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

@@ -0,0 +1,31 @@
+# Agent Note: Consumer-owned startup strictness
+
+Status: implemented
+
+English | [中文](2026-09-09-consumer-owned-startup-strictness.zh.md)
+
+## Problem
+
+Best-effort Loader reconciliation preserves usable plugins, but applications still need a minimum set of capabilities. An HTTP application without its listening server is not running, while an unavailable tool can be omitted without making the remaining application unusable. Cordis cannot infer this distinction from plugin implementation or dependency state.
+
+## Decision
+
+DSH owns startup strictness outside vendored Cordis. App-boot audits the settled initial tree against one global list of stable entry ids. A listed entry that is present, enabled, and not active rejects startup and disposes the application. A listed id that is absent or disabled has no effect. Every other inactive entry produces one warning and leaves successful siblings running.
+
+The required ids are `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. They represent shared Agent execution and the endpoints of the shipped Web, headless, ACP, and SDK applications. Their injected providers do not need separate list entries: a missing provider leaves the listed endpoint pending or failed.
+
+The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair.
+
+## Alternatives considered
+
+- **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group.
+- **Declare required entries in each profile.** Rejected because the same application endpoints would be duplicated across profile data and custom profiles. A global list treats missing ids as irrelevant while keeping stable shipped ids authoritative.
+- **Make every startup failure optional.** Rejected because a process that cannot expose its selected application endpoint must report launch failure.
+
+## 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 use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root.
+
+## 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 when the required HTTP port is occupied.

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

@@ -0,0 +1,31 @@
+# Agent Note:由 consumer 持有启动严格语义
+
+Status: implemented
+
+[English](2026-09-09-consumer-owned-startup-strictness.md) | 中文
+
+## 问题
+
+Best-effort Loader reconcile 会保留可用 plugin,但应用仍需一组最小 capability。HTTP 应用没有 listening server 就不算运行,而一个 tool 不可用时可以仅省略该 tool,剩余应用仍然可用。Cordis 无法从 plugin 实现或依赖状态推断这一区别。
+
+## 决策
+
+DSH 在 vendored Cordis 之外持有启动严格语义。App-boot 用一份全局稳定 entry id list 审计已结算的初始 tree。List 中存在、启用且未 active 的 entry 会使启动 reject,并拆卸应用。List 中缺失或禁用的 id 不产生影响。其他 inactive entry 输出一次 warning,并让成功 sibling 继续运行。
+
+Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。它们分别代表共享 Agent 执行,以及随附 Web、headless、ACP 和 SDK 应用的 endpoint。其 injected provider 不需要单列:provider 缺失会让已列出的 endpoint 保持 pending 或失败。
+
+该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。
+
+## 考虑过的替代方案
+
+- **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。
+- **在每个 profile 中声明 required entry。** 拒绝,因为相同应用 endpoint 会在 profile data 与 custom profile 中重复。全局 list 会忽略缺失 id,同时让稳定的随附 id 保持权威。
+- **把所有启动失败都视为 optional。** 拒绝,因为无法暴露所选应用 endpoint 的进程必须报告启动失败。
+
+## 后果
+
+稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。
+
+## 测试
+
+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 被占用时以非零码退出。

+ 9 - 6
apps/cli/tests/built-bin.e2e.ts

@@ -28,6 +28,7 @@ const SPAWN_TIMEOUT_MS = 60_000
 const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
 const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
 const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
+const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
 
 async function runBuiltBin(
   args: readonly string[] = [],
@@ -400,7 +401,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 3 + 30_000)
 
-  it('reports SDK startup failure when stdin reaches EOF first', async () => {
+  it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
     const patch = join(home, 'broken-sdk.cordis.yml')
     writeFileSync(patch, [
@@ -415,9 +416,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         DSH_TELEMETRY_DISABLED: '1',
         DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
       }, home)
-      expect(result.code).toBe(1)
+      expect(result.code).toBe(0)
       expect(result.stdout).toBe('')
-      expect(result.stderr).toContain('plugin tree failed to load')
+      expect(result.stderr).toContain('warning: 1 entry did not activate')
       expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
     } finally {
       rmSync(home, { recursive: true, force: true })
@@ -756,16 +757,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
-  it('reports a patch-overlay boot failure without hanging', async () => {
+  it('keeps serving when an optional patch-overlay plugin fails', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
     try {
       const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
         DSH_HOME: home,
+        DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
         DEEPSEEK_API_KEY: 'keyless-invalid-config',
         DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: `--import=${webReadyExitHook}`,
       })
-      expect(result.code).toBe(1)
-      expect(result.stdout).toBe('')
+      expect(result.code).toBe(0)
+      expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
       expect(result.stderr).toContain('llm-pi-ai')
     } finally {
       rmSync(home, { recursive: true, force: true })

+ 1 - 1
apps/cli/tests/fixtures/invalid-provider.cordis.yml

@@ -1,4 +1,4 @@
-# Invalid `--patch` overlay used to prove boot failures settle and exit.
+# Invalid optional provider override used to prove best-effort startup continues.
 
 - id: llm-pi-ai
   config:

+ 4 - 2
apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt

@@ -1,3 +1,5 @@
-headless-test-driver: plugin tree failed to load: headless-test-driver: 1 entry did not activate
-./activation-error.mjs: Error: startup activation snapshot failure
+dsh: warning: 1 entry did not activate
+activation-error (./activation-error.mjs): Error: startup activation snapshot failure
     at activation-error-fixture
+dsh: reasoning:
+Inspecting the task before the tool call.

+ 1 - 7
apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml

@@ -1,10 +1,4 @@
-# Activation-failure patch over the shipped headless profile.
-- id: headless-startup
-  disabled: true
-
-- id: headless-runner
-  disabled: true
-
+# Unrelated activation failure over the shipped headless profile.
 - insert:
     - id: activation-error
       name: ./activation-error.mjs

+ 16 - 8
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -271,19 +271,27 @@ describe('headless stream-json snapshots', () => {
     await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('prints the original Loader activation error through the assembled one-shot app', async () => {
+  it('warns about an unrelated activation error and completes the headless task', async () => {
     const result = await runLoaderSmoke({
-      label: 'headless startup activation error snapshot',
+      label: 'headless best-effort startup snapshot',
       tempDirPrefix: 'headless-snapshot-startup-error-',
-      binScript,
-      libBinScript: binScript,
+      binScript: dshBinScript,
       configPath: startupFailureConfigPath,
-      binArgs: [startupFailureConfigPath, 'unreachable task'],
+      binArgs: [
+        '--profile', 'headless',
+        '--patch', headlessOverlayPath,
+        '--patch', startupFailureConfigPath,
+        'Complete the task despite the unrelated startup failure.',
+      ],
       tsconfigPath,
-      expectedExitCode: 1,
+      env: {
+        DSH_PERMISSION_MODE: 'danger-full-access',
+        DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+      },
     })
-    expect(result.stdout).toBe('')
-    await expect(result.stderr.replace(startupFailurePluginUrl, './activation-error.mjs'))
+    expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
+    await expect(result.stderr.replaceAll(startupFailurePluginUrl, './activation-error.mjs'))
       .toMatchFileSnapshot(startupFailureExpected)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 

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

@@ -355,7 +355,8 @@ describe('Python SDK dsh profile keyless smoke', () => {
       expect(exitCode, stderr).toBe(1)
       expect(stdout).toBe('')
       expect(stderr).toContain('plugin tree failed to load')
-      expect(stderr).toContain('@deepseek-ai/dsh-sdk-jsonrpc-server: SyntaxError:')
+      expect(stderr).toContain('required startup failure')
+      expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError')
       expect(stderr).toContain('sometimes')
     } finally {
       await rm(root, { recursive: true, force: true })

+ 243 - 0
apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts

@@ -0,0 +1,243 @@
+/** Built Web-profile acceptance for best-effort initial plugin activation. */
+
+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 type { Readable } from 'node:stream'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
+const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
+const frontendIndex = join(repoRoot, 'apps/web/dist/index.html')
+const builtArtifactsExist = existsSync(dshBin) && existsSync(frontendIndex)
+
+interface Fixture {
+  root: string
+  home: string
+  patch: string
+  events: string
+  stop: string
+}
+
+function createFixture(): Fixture {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-web-best-effort-'))
+  const home = join(root, 'home')
+  const events = join(root, 'events.log')
+  const stop = join(root, 'stop')
+  mkdirSync(home)
+  writeFileSync(events, '')
+  writeFileSync(join(root, 'good.mjs'), [
+    "import { appendFileSync, existsSync } from 'node:fs'",
+    'export function apply(ctx, config) {',
+    "  appendFileSync(config.events, 'good apply\\n')",
+    '  let stopping = false',
+    '  const watcher = setInterval(() => {',
+    '    if (stopping || !existsSync(config.stop)) return',
+    '    stopping = true',
+    "    process.emit('SIGTERM')",
+    '  }, 20)',
+    '  ctx.effect(() => () => {',
+    '    clearInterval(watcher)',
+    "    appendFileSync(config.events, 'good dispose\\n')",
+    '  })',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'sync-failure.mjs'), 'export function apply() { throw new Error("web sync apply failure") }\n')
+  writeFileSync(join(root, 'async-failure.mjs'), [
+    'export async function apply() {',
+    '  await Promise.resolve()',
+    '  throw new Error("web async apply failure")',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(root, 'pending.mjs'), [
+    "export const inject = ['webProbeMissingService']",
+    'export function apply() {}',
+    '',
+  ].join('\n'))
+  const patch = join(root, 'failures.patch.yml')
+  writeFileSync(patch, [
+    '- id: tool-todo',
+    '  disabled: false',
+    '  config: {}',
+    '- insert:',
+    '    - id: web-probe-good',
+    `      name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
+    '      config:',
+    `        events: ${JSON.stringify(events)}`,
+    `        stop: ${JSON.stringify(stop)}`,
+    '    - id: web-probe-import-failure',
+    `      name: ${pathToFileURL(join(root, 'missing.mjs')).href}`,
+    '    - id: web-probe-sync-failure',
+    `      name: ${pathToFileURL(join(root, 'sync-failure.mjs')).href}`,
+    '    - id: web-probe-async-failure',
+    `      name: ${pathToFileURL(join(root, 'async-failure.mjs')).href}`,
+    '    - id: web-probe-pending',
+    `      name: ${pathToFileURL(join(root, 'pending.mjs')).href}`,
+    '',
+  ].join('\n'))
+  return { root, home, patch, events, stop }
+}
+
+async function waitForStartup(
+  stdout: Readable | null,
+  stderr: Readable | null,
+  completion: PromiseLike<{ exitCode?: number }>,
+): Promise<{ url: string; stderr: string }> {
+  if (stdout === null || stderr === null) throw new Error('Web child pipes are unavailable')
+  stdout.setEncoding('utf8')
+  stderr.setEncoding('utf8')
+  let stdoutText = ''
+  let stderrText = ''
+  let url: string | undefined
+  const ready = Promise.withResolvers<{ url: string; stderr: string }>()
+  let settled = false
+  const finish = (): void => {
+    if (settled || url === undefined) return
+    if (!stderrText.includes('dsh: warning: 5 entries did not activate')) return
+    if (!stderrText.includes('web async apply failure')) return
+    if (!stderrText.includes('webProbeMissingService')) return
+    settled = true
+    clearTimeout(timer)
+    ready.resolve({ url, stderr: stderrText })
+  }
+  stdout.on('data', (chunk: string) => {
+    stdoutText += chunk
+    url ??= /dsh web: (http:\/\/[^\s]+)/u.exec(stdoutText)?.[1]
+    finish()
+  })
+  stderr.on('data', (chunk: string) => {
+    stderrText += chunk
+    finish()
+  })
+  const timer = setTimeout(() => {
+    if (settled) return
+    settled = true
+    ready.reject(new Error(`Web profile did not report ready and failed entries\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
+  }, 60_000)
+  void completion.then((result) => {
+    if (settled) return
+    settled = true
+    clearTimeout(timer)
+    ready.reject(new Error(`Web profile exited before readiness (code ${String(result.exitCode)})\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
+  })
+  return ready.promise
+}
+
+describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () => {
+  it('serves the full Web app while unrelated entries fail to start', async () => {
+    const fixture = createFixture()
+    const child = execa(process.execPath, [
+      dshBin,
+      '--profile', 'web',
+      '--patch', fixture.patch,
+      '--no-open',
+      '--port', '0',
+    ], {
+      cwd: fixture.root,
+      env: {
+        ...process.env,
+        DEEPSEEK_API_KEY: 'keyless-web-best-effort-no-call',
+        DSH_AGENTS_HOME: join(fixture.root, '.agents'),
+        DSH_HOME: fixture.home,
+        DSH_TELEMETRY_DISABLED: '1',
+        NODE_NO_WARNINGS: '1',
+      },
+      input: '',
+      reject: false,
+      timeout: 90_000,
+      killSignal: 'SIGKILL',
+    })
+
+    let result: Awaited<typeof child>
+    let events = ''
+    try {
+      const startup = await waitForStartup(child.stdout, child.stderr, child)
+      const auth = await fetch(startup.url, { redirect: 'manual' })
+      const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
+      if (cookie === undefined) throw new Error('Web authentication response did not set a cookie')
+      const page = await fetch(new URL('/', startup.url), { headers: { cookie } })
+      expect(await page.text()).toContain('<div id="root"></div>')
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n')
+      expect(startup.stderr).toContain('web-probe-import-failure')
+      expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo')
+      expect(startup.stderr).toContain('web sync apply failure')
+      expect(startup.stderr).toContain('web async apply failure')
+      expect(startup.stderr).toContain('pending (waiting for service: webProbeMissingService)')
+    } finally {
+      writeFileSync(fixture.stop, 'stop')
+      result = await child
+      events = readFileSync(fixture.events, 'utf8')
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+
+    expect({
+      exitCode: result.exitCode,
+      timedOut: result.timedOut,
+      events,
+    }).toMatchInlineSnapshot(`
+      {
+        "events": "good apply
+      good dispose
+      ",
+        "exitCode": 0,
+        "timedOut": false,
+      }
+    `)
+  })
+
+  it('fails the full Web profile when its required HTTP server cannot bind', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
+    const home = join(root, 'home')
+    mkdirSync(home)
+    const blocker = createServer()
+    await new Promise<void>((resolve, reject) => {
+      const fail = (error: Error): void => { reject(error) }
+      blocker.once('error', fail)
+      blocker.listen(0, '127.0.0.1', () => {
+        blocker.off('error', fail)
+        resolve()
+      })
+    })
+    const address = blocker.address()
+    if (address === null || typeof address === 'string') {
+      throw new Error('port blocker did not bind a TCP address')
+    }
+
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--no-open',
+        '--port', String(address.port),
+      ], {
+        cwd: root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-required-bind-no-call',
+          DSH_AGENTS_HOME: join(root, '.agents'),
+          DSH_HOME: home,
+          DSH_TELEMETRY_DISABLED: '1',
+          NODE_NO_WARNINGS: '1',
+        },
+        input: '',
+        reject: false,
+        timeout: 90_000,
+        killSignal: 'SIGKILL',
+      })
+      expect(result.exitCode).toBe(1)
+      expect(result.stdout).not.toContain('dsh web: http://')
+      expect(result.stderr).toContain('required startup failure')
+      expect(result.stderr).toContain('EADDRINUSE')
+    } finally {
+      await new Promise<void>((resolve, reject) => {
+        blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })
+      })
+      rmSync(root, { recursive: true, force: true })
+    }
+  })
+})

+ 2 - 2
apps/web/tests/scaffold.ts

@@ -57,7 +57,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import {
-  assertEntriesLoaded,
+  auditStartupEntries,
   composeEntries,
   healProfilesModuleFallback,
   loadOverlayPatches,
@@ -711,7 +711,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       config: { path: pathToFileURL(rootConfig).href, patches },
     })
     await ctx.loader.await()
-    assertEntriesLoaded(ctx, 'web e2e scaffold')
+    await auditStartupEntries(ctx, 'web e2e scaffold')
     if (options.welcomeNoticePending !== true) {
       await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
         op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,

+ 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: 28a3c75c34e3cd8882e2b9447fecbb77634e02eb
-README.zh.md: 50d6096f0971886ea70bdf6676e4ae33c9a93c2d
+README.md: 8b8b141a58ff4facfe4d07002464ef83a990f26f
+README.zh.md: 20f0d72f507e8ee5e347287c2d2f09cbf6995b5b

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

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-With that entry point, success looks like a running app with every plugin active; failure is never silent — one labelled line names the failing plugin and the stage, and the process exits nonzero. The app context is torn down before the error is reported, so nothing keeps running half-started.
+With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution and each application's endpoint: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
 
 <a id="profiles"></a>
 ### Profiles
@@ -64,7 +64,18 @@ Before you boot, you can print the exact configuration the app will mount: the d
 
 ### What you see when startup fails
 
-Startup failure is a single labelled line plus a nonzero exit — never a silent hang or a raw stack dump. The message names the failing plugin; a plugin that threw keeps its original error, and an entry that never started is reported with the services it was waiting for.
+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.
+
+| Failure pattern | Entry result | Startup action |
+|---|---|---|
+| The root YAML cannot be read or parsed, is not an entry list, or repeats an id in one group | No valid candidate tree | 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 |
+| 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()` rejects | Fiber is `FAILED` with the rejection | 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 |
+
+The Loader consumes activation rejections while reconciling the group, and app-boot awaits a failed fiber only to recover its recorded reason. The process-level fail-loud handler is reserved for detached asynchronous failures that no Loader entry owns.
 
 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.
 
@@ -86,6 +97,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.
 - **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.
 - **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.
 - **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.

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

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-有了这个入口,成功就是每个插件都已激活的运行中应用;失败绝不会悄无声息——一行带标签的信息点名失败的插件与阶段,进程以非零码退出。错误上报前会先拆卸应用上下文,因此不会留下半启动的残留。
+有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行与各应用的 endpoint:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
 
 <a id="profiles"></a>
 ### Profile
@@ -64,7 +64,18 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 ### 启动失败时你会看到什么
 
-启动失败是一行带标签的信息加非零退出码——绝不是静默卡死或原始堆栈转储。信息会点名失败的插件;抛错的插件保留原始错误,从未启动的条目会连同它等待的服务一起报告。
+Loader 结算后,app-boot 按稳定 id 对每个已启用 entry 分类。Optional failure 输出一次警告,并让 active sibling 继续运行。Required failure 输出相同的 entry 详情,然后拆卸应用并拒绝启动。
+
+| 失败模式 | Entry 结果 | 启动措施 |
+|---|---|---|
+| 根 YAML 无法读取或解析、不是 entry list,或同一 group 内 id 重复 | 没有有效 candidate tree | 拒绝并拆卸;不接受部分应用 |
+| Plugin module 无法 import | Entry 没有 fiber | Optional 时警告;required 时拒绝并拆卸 |
+| Config expression 求值或 plugin config schema 在 activation 时失败 | Fiber 为 `FAILED`,保留校验错误 | Optional 时警告;required 时拒绝并拆卸 |
+| 同步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
+| 异步 `apply()` reject | Fiber 为 `FAILED`,保留 rejection | Optional 时警告;required 时拒绝并拆卸 |
+| 必需的 injected service 始终未出现 | Fiber 保持 `PENDING`,并指出缺失 service | Optional 时警告;required 时拒绝并拆卸 |
+
+Loader 在 reconcile group 时消费 activation rejection;app-boot 只为取得已记录的原因而 await failed fiber。进程级 fail-loud handler 只处理不属于任何 Loader entry 的 detached asynchronous failure。
 
 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。
 
@@ -86,6 +97,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 - **与渠道无关的库。** 此包不包含 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。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。

+ 110 - 51
packages/boot/app-boot/src/index.ts

@@ -271,7 +271,8 @@ export async function watchUserPatches(
     })
     await ctx.loader.await()
     await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await())))
-    await assertEntriesActivated(ctx, binName)
+    const failures = await inactiveEntries(ctx)
+    if (failures.length > 0) ctx.logger.warn(activationDiagnostic(binName, 'warning', failures))
   })
   try {
     return await register
@@ -614,7 +615,7 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
 /**
  * Install before boot to turn a late unhandled plugin-init rejection into one
  * labelled stderr diagnostic and `exit(1)`. A rejection already included by
- * {@link assertEntriesActivated} is ignored during its process checkpoint;
+ * {@link auditStartupEntries} is ignored during its process checkpoint;
  * every other rejection remains fatal. Stdout remains untouched for ACP; the
  * returned function removes the handler.
  *
@@ -682,21 +683,6 @@ export function installFailLoud(
   return uninstall
 }
 
-/**
- * After the tree settles, reject entries with no fiber and name every plugin
- * whose module failed to resolve. Disabled entries are the only valid
- * fiber-less state.
- * @param ctx - the settled context whose loader entries to audit.
- * @param binName - the diagnostic prefix on the thrown error.
- */
-export function assertEntriesLoaded(ctx: Context, binName: string): void {
-  const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
-  if (failed.length > 0) {
-    const names = failed.map(entry => entry.options.name).join(', ')
-    throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
-  }
-}
-
 /**
  * Value mirrors used because Cordis's const enum has no runtime object to import.
  * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
@@ -706,34 +692,68 @@ const FIBER_PENDING = 0 as FiberState.PENDING
 const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const FIBER_FAILED = 3 as FiberState.FAILED
 
-/** Render plugin stacks, nested causes, and aggregate member failures. */
+/**
+ * Entry ids whose presence defines a usable DSH application.
+ *
+ * The list is global rather than profile metadata. Missing or disabled ids do
+ * not affect startup; an enabled listed entry must activate. Surface endpoints
+ * cover their injected providers, while `agent-loop` covers shared Agent
+ * execution.
+ */
+export const REQUIRED_STARTUP_ENTRY_IDS: readonly string[] = Object.freeze([
+  'agent-loop',
+  'webserver',
+  'modules',
+  'connection',
+  'headless-runner',
+  'acp',
+  'sdk-jsonrpc-server',
+])
+
+const requiredStartupEntryIds = new Set<string>(REQUIRED_STARTUP_ENTRY_IDS)
+
+/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
 function formatActivationError(error: unknown): string {
-  if (!(error instanceof Error)) return String(error)
-  const details = [error.stack ?? error.message]
-  if (error.cause !== undefined) details.push(formatActivationError(error.cause))
-  if (error instanceof AggregateError) details.push(...error.errors.map(formatActivationError))
+  const details: string[] = []
+  const seen = new Set<Error>()
+  function visit(value: unknown): void {
+    if (!(value instanceof Error)) {
+      details.push(String(value))
+      return
+    }
+    if (seen.has(value)) return
+    seen.add(value)
+    details.push(value.stack ?? value.message)
+    if (value.cause !== undefined) visit(value.cause)
+    if (value instanceof AggregateError) value.errors.forEach(visit)
+  }
+  visit(error)
   return details.join('\n')
 }
 
+interface InactiveEntry {
+  /** Loader entry id used by the required-startup policy. */
+  id: string
+  /** Complete diagnostic beginning with the entry id and module specifier. */
+  diagnostic: string
+}
+
 /**
- * Reject a settled Loader tree when an enabled entry failed or remains inactive.
- * Plugin failures include original stacks, nested causes, and aggregate members.
- * Pending entries name their unresolved services because no plugin error exists for that state. Active
- * entries require no further wait; only failed fibers are awaited to recover
- * their private rejection reason.
- * @param ctx - the settled context whose Loader entries to audit.
- * @param binName - the diagnostic prefix on the thrown error.
- * @returns nothing when every enabled entry is active.
- * @throws after one process rejection checkpoint when an entry failed to
- * import, rejected during activation, or did not become active.
+ * Collect enabled Loader entries that did not become active. Failed fibers are
+ * awaited to recover their recorded rejection reason and coalesce duplicate
+ * Loader notifications through the next process rejection checkpoint.
  */
-export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
-  assertEntriesLoaded(ctx, binName)
-  const failures: string[] = []
+async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
+  const failures: InactiveEntry[] = []
   const rejectionReasons: unknown[] = []
   for (const entry of ctx.loader.entries()) {
     const fiber = entry.fiber
-    if (fiber === undefined || entry.disabled) continue
+    if (entry.disabled) continue
+    const subject = `${entry.options.id} (${entry.options.name})`
+    if (fiber === undefined) {
+      failures.push({ id: entry.options.id, diagnostic: `${subject}: failed to import` })
+      continue
+    }
     const state = fiber.state
     if (state === FIBER_ACTIVE) continue
     if (state === FIBER_FAILED) {
@@ -741,24 +761,62 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
         await fiber.await()
       } catch (error) {
         rejectionReasons.push(error)
-        failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
+        failures.push({ id: entry.options.id, diagnostic: `${subject}: ${formatActivationError(error)}` })
       }
       continue
     }
     if (state === FIBER_PENDING) {
       const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
-      const subject = missing.length === 1 ? 'service' : 'services'
-      failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
+      failures.push({
+        id: entry.options.id,
+        diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
+      })
     } else {
-      failures.push(`${entry.options.name}: fiber state ${String(state)}`)
+      failures.push({ id: entry.options.id, diagnostic: `${subject}: fiber state ${String(state)}` })
     }
   }
-  if (failures.length > 0) {
-    if (rejectionReasons.length > 0) {
-      await observeLoaderRejectionCheckpoint(rejectionReasons)
-    }
-    const noun = failures.length === 1 ? 'entry' : 'entries'
-    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
+  if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
+  return failures
+}
+
+/** Render an inactive-entry diagnostic with a count and severity label. */
+function activationDiagnostic(
+  binName: string,
+  severity: 'warning' | 'required startup failure',
+  failures: readonly InactiveEntry[],
+): string {
+  const noun = failures.length === 1 ? 'entry' : 'entries'
+  const prefix = binName === '' ? '' : `${binName}: `
+  return `${prefix}${severity}: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => failure.diagnostic).join('\n')}\n`
+}
+
+/**
+ * Apply DSH startup policy to a settled Loader tree.
+ *
+ * Inactive entries from the global required list reject startup. Other
+ * inactive entries produce one warning and leave successful siblings running.
+ * Required ids absent from the tree, and disabled required entries, are ignored.
+ * @param ctx - the settled context whose Loader entries to audit.
+ * @param binName - the diagnostic prefix on optional-entry warnings.
+ * @param warn - sink for optional-entry warnings.
+ * @returns after all optional failures are warned when no required entry failed.
+ * @throws when an enabled entry in {@link REQUIRED_STARTUP_ENTRY_IDS} is inactive.
+ */
+export async function auditStartupEntries(
+  ctx: Context,
+  binName: string,
+  warn: (line: string) => void = line => void process.stderr.write(line),
+): Promise<void> {
+  const failures = await inactiveEntries(ctx)
+  const required: InactiveEntry[] = []
+  const optional: InactiveEntry[] = []
+  for (const failure of failures) {
+    const target = requiredStartupEntryIds.has(failure.id) ? required : optional
+    target.push(failure)
+  }
+  if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional))
+  if (required.length > 0) {
+    throw new Error(activationDiagnostic('', 'required startup failure', required).trimEnd())
   }
 }
 
@@ -770,10 +828,11 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * is statically imported and mounted as the `cordis:include` builtin, loading
  * through the ambient module pipeline (vite/tsx/plain ESM). The package build
  * embeds Include while leaving Loader external, so the built include tree and
- * host share one Loader peer. The final audit, {@link assertEntriesActivated},
- * rejects missing, failed, or inactive entries. `boot` disposes its partial
- * context on failure and preserves the original activation stack; later unhandled rejections remain
- * covered by {@link installFailLoud}. Built bins need the Loader's native
+ * host share one Loader peer. Loader settlement drains entry work without
+ * rejecting the whole tree. The final {@link auditStartupEntries} call rejects
+ * failures in the global required list and warns about other failed, missing,
+ * and pending entries while successful siblings remain active. Later unhandled
+ * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
  * helper for bare plugin specifiers; relative specifiers do not.
  * @param binName - the diagnostic prefix for load-failure errors.
  * @param absoluteConfigPath - the config to include; must already be absolute
@@ -820,7 +879,7 @@ export async function boot(
     // as asked. Re-check after settlement before auditing the tree.
     await ctx.get('loader')?.await()
     if (ctx.get('loader') === undefined) return ctx
-    await assertEntriesActivated(ctx, binName)
+    await auditStartupEntries(ctx, binName)
     return ctx
   } catch (cause) {
     // Root-fiber disposal contains cleanup failures per observer (Cordis

+ 147 - 127
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -6,9 +6,10 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
-  addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
+  addHarnessSourceSection, auditStartupEntries, boot,
   FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
   installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
+  REQUIRED_STARTUP_ENTRY_IDS,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -419,10 +420,11 @@ describe('installFailLoud', () => {
     const proc = fakeProc()
     installFailLoud(NAME, proc)
     const error = new Error('assembled activation failure')
-    const audit = assertEntriesActivated({
+    const warn = vi.fn()
+    const audit = auditStartupEntries({
       loader: {
         entries: () => ['broken-a', 'broken-b'].map(name => ({
-          options: { name },
+          options: { id: name, name },
           fiber: {
             state: 3,
             inject: {},
@@ -431,13 +433,14 @@ describe('installFailLoud', () => {
           },
         })),
       },
-    } as unknown as Context, NAME)
+    } as unknown as Context, NAME, warn)
     await Promise.resolve()
     await Promise.resolve()
     proc.handlers[0]!(error)
     expect(proc.written).toEqual([])
     expect(proc.exits).toEqual([])
-    await expect(audit).rejects.toThrow('assembled activation failure')
+    await audit
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure'))
     proc.handlers[0]!(error)
     expect(proc.exits).toEqual([1])
   })
@@ -501,36 +504,20 @@ describe('installFailLoud', () => {
   })
 })
 
-describe('assertEntriesLoaded', () => {
-  const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
-    ({ loader: { entries: () => entries } }) as unknown as Context
-
-  it('passes when every enabled entry has a fiber', () => {
-    expect(() => { assertEntriesLoaded(ctxWith([
-      { fiber: {}, options: { name: 'a' } },
-      { disabled: true, options: { name: 'off' } },
-    ]), NAME) }).not.toThrow()
-  })
-
-  it('throws naming every enabled fiber-less entry', () => {
-    expect(() => { assertEntriesLoaded(ctxWith([
-      { fiber: {}, options: { name: 'ok' } },
-      { options: { name: 'broken-a' } },
-      { options: { name: 'broken-b' } },
-    ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
-  })
-})
-
-describe('assertEntriesActivated', () => {
-  interface FakeFiber {
-    state: number
-    inject: Record<string, unknown>
-    ctx: { get(name: string): unknown }
-    await(): Promise<unknown>
+describe('auditStartupEntries', () => {
+  interface FakeEntry {
+    fiber?: {
+      state: number
+      inject: Record<string, unknown>
+      ctx: { get(name: string): unknown }
+      await(): Promise<unknown>
+    }
+    disabled?: boolean
+    options: { id: string; name: string }
   }
 
-  const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
-    loader: { entries: () => entries },
+  const ctxWith = (entries: FakeEntry[]): Context => ({
+    loader: { entries: () => entries.values() },
   }) as unknown as Context
 
   const fiber = (
@@ -538,95 +525,99 @@ describe('assertEntriesActivated', () => {
     error?: unknown,
     inject: Record<string, unknown> = {},
     services: string[] = [],
-  ): FakeFiber => ({
+  ): NonNullable<FakeEntry['fiber']> => ({
     state,
     inject,
     ctx: { get: name => services.includes(name) ? {} : undefined },
     await: error === undefined ? async () => undefined : async () => { throw error },
   })
 
-  it('passes active entries and ignores disabled entries', async () => {
-    let awaitCalls = 0
-    const active = fiber(2)
-    active.await = async () => {
-      awaitCalls++
-      return undefined
-    }
-    const disabled = fiber(3, new Error('disabled failure'))
-    disabled.await = async () => {
-      awaitCalls++
-      throw new Error('disabled failure')
-    }
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: active, options: { name: 'active' } },
-      { fiber: disabled, disabled: true, options: { name: 'disabled' } },
-    ]), NAME)).resolves.toBeUndefined()
-    expect(awaitCalls).toBe(0)
+  it('pins the global list to shared execution and application endpoints', () => {
+    expect(Object.isFrozen(REQUIRED_STARTUP_ENTRY_IDS)).toBe(true)
+    expect(REQUIRED_STARTUP_ENTRY_IDS).toEqual([
+      'agent-loop',
+      'webserver',
+      'modules',
+      'connection',
+      'headless-runner',
+      'acp',
+      'sdk-jsonrpc-server',
+    ])
+    expect(REQUIRED_STARTUP_ENTRY_IDS).not.toContain('tool-todo')
   })
 
-  it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
-    const original = new Error('actual plugin failure')
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
-    ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
+  it('ignores active, disabled, and absent required entries', async () => {
+    const warn = vi.fn()
+    await expect(auditStartupEntries(ctxWith([
+      { fiber: fiber(2), options: { id: 'active', name: 'active' } },
+      { fiber: fiber(3, new Error('disabled failure')), disabled: true, options: { id: 'webserver', name: 'disabled' } },
+    ]), NAME, warn)).resolves.toBeUndefined()
+    expect(warn).not.toHaveBeenCalled()
   })
 
-  it('formats stackless and non-Error activation failures', async () => {
-    const stackless = new Error('stackless failure')
-    delete (stackless as { stack?: string }).stack
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(3, stackless), options: { name: 'stackless' } },
-      { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
-    ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
+  it('warns once for optional import, apply, and dependency failures', async () => {
+    const warn = vi.fn()
+    const original = new Error('todo apply failure')
+    await auditStartupEntries(ctxWith([
+      { options: { id: 'missing-tool', name: './missing.mjs' } },
+      { fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
+      {
+        fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']),
+        options: { id: 'waiting-tool', name: './waiting.mjs' },
+      },
+    ]), NAME, warn)
+    expect(warn).toHaveBeenCalledOnce()
+    expect(warn).toHaveBeenCalledWith([
+      `${NAME}: warning: 3 entries did not activate`,
+      'missing-tool (./missing.mjs): failed to import',
+      `tool-todo (@deepseek-ai/dsh-tool-todo): ${original.stack!}`,
+      'waiting-tool (./waiting.mjs): pending (waiting for service: missing)',
+      '',
+    ].join('\n'))
   })
 
   it('preserves nested activation causes and aggregate member failures', async () => {
+    const warn = vi.fn()
     const original = new Error('tool discovery failed')
     const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', {
       cause: new Error('server rejected discovery'),
     })
     const wrapper = new Error('plugin activation failed', { cause: aggregate })
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(3, wrapper), options: { name: 'wrapped-plugin' } },
-    ]), NAME)).rejects.toThrow([
-      `${NAME}: 1 entry did not activate`,
-      `wrapped-plugin: ${wrapper.stack!}`,
+    await auditStartupEntries(ctxWith([
+      { fiber: fiber(3, wrapper), options: { id: 'wrapped-plugin', name: './wrapped.mjs' } },
+    ]), NAME, warn)
+    expect(warn).toHaveBeenCalledWith([
+      `${NAME}: warning: 1 entry did not activate`,
+      `wrapped-plugin (./wrapped.mjs): ${wrapper.stack!}`,
       aggregate.stack!,
       (aggregate.cause as Error).stack!,
       original.stack!,
       'transport closed',
+      '',
     ].join('\n'))
   })
 
-  it('reports unresolved services for pending entries', async () => {
-    let awaitCalls = 0
-    const expected = [
-      `${NAME}: 3 entries did not activate`,
-      'waiting: pending (waiting for services: missingA, missingB)',
-      'single-wait: pending (waiting for service: missing)',
-      'unknown-wait: pending (waiting for services: unknown)',
-    ].join('\n')
-    const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
-    const singleWait = fiber(0, undefined, { missing: {} })
-    const unknownWait = fiber(0)
-    for (const item of [waiting, singleWait, unknownWait]) {
-      item.await = async () => {
-        awaitCalls++
-        return undefined
-      }
-    }
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: waiting, options: { name: 'waiting' } },
-      { fiber: singleWait, options: { name: 'single-wait' } },
-      { fiber: unknownWait, options: { name: 'unknown-wait' } },
-    ]), NAME)).rejects.toThrow(expected)
-    expect(awaitCalls).toBe(0)
+  it('rejects required failures after warning about optional failures', async () => {
+    const warn = vi.fn()
+    const requiredError = new Error('address already in use')
+    const optionalError = new Error('todo unavailable')
+    await expect(auditStartupEntries(ctxWith([
+      { fiber: fiber(3, requiredError), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } },
+      { fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
+    ]), NAME, warn)).rejects.toThrow([
+      'required startup failure: 1 entry did not activate',
+      `webserver (@deepseek-ai/dsh-host-webserver): ${requiredError.stack!}`,
+    ].join('\n'))
+    expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
   })
 
-  it('retains the numeric diagnostic for a settled unexpected state', async () => {
-    await expect(assertEntriesActivated(ctxWith([
-      { fiber: fiber(4), options: { name: 'disposed' } },
-    ]), NAME)).rejects.toThrow('disposed: fiber state 4')
+  it('rejects a required entry pending on an injected service', async () => {
+    await expect(auditStartupEntries(ctxWith([{
+      fiber: fiber(0, undefined, { webRuntime: {} }),
+      options: { id: 'connection', name: '@deepseek-ai/dsh-client-connection' },
+    }]), NAME, vi.fn())).rejects.toThrow(
+      'connection (@deepseek-ai/dsh-client-connection): pending (waiting for service: webRuntime)',
+    )
   })
 })
 
@@ -818,48 +809,86 @@ describe('boot', () => {
     expect(ctx.get('loader')).toBeUndefined()
   })
 
-  it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
-    const dir = tmp()
-    writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n  name: ./missing.mjs\n')
-    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
-      `${NAME}: plugin tree failed to load: ${NAME}: plugin(s) failed to load: ./missing.mjs`,
-    )
-  })
-
-  it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
+  it('keeps successful entries and warns about optional import, config, sync apply, async apply, and dependency failures', async () => {
     const dir = tmp()
     const configPath = join(dir, 'cordis.yml')
     const config = [
+      '- id: good',
+      '  name: ./good.mjs',
+      '- id: import-failure',
+      '  name: ./missing.mjs',
       '- id: invalid-config',
       '  name: ./noop.mjs',
       '  config:',
       '    value: !!js "JSON.parse(\'invalid\')"',
+      '- id: sync-failure',
+      '  name: ./sync-failure.mjs',
+      '- id: async-failure',
+      '  name: ./async-failure.mjs',
+      '- id: waiting',
+      '  name: ./waiting.mjs',
       '',
     ].join('\n')
+    writeFileSync(join(dir, 'good.mjs'), 'export function apply(ctx) { ctx.provide("goodStarted", true) }\n')
     writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
+    writeFileSync(join(dir, 'sync-failure.mjs'), 'export function apply() { throw new Error("sync apply failure") }\n')
+    writeFileSync(join(dir, 'async-failure.mjs'), 'export async function apply() { throw new Error("async apply failure") }\n')
+    writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
     writeFileSync(configPath, config)
 
-    await expect(boot(NAME, configPath)).rejects.toThrow(
-      './noop.mjs: SyntaxError:',
-    )
-    expect(readFileSync(configPath, 'utf8')).toBe(config)
+    const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+    let ctx: Context | undefined
+    try {
+      ctx = await boot(NAME, configPath)
+      expect(ctx.get('goodStarted')).toBe(true)
+      const entries = [...ctx.loader.entries()]
+      expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)
+      expect(entries.find(entry => entry.options.id === 'import-failure')?.fiber).toBeUndefined()
+      for (const id of ['invalid-config', 'sync-failure', 'async-failure']) {
+        expect(entries.find(entry => entry.options.id === id)?.fiber?.state).toBe(3)
+      }
+      expect(entries.find(entry => entry.options.id === 'waiting')?.fiber?.state).toBe(0)
+      const warning = write.mock.calls.map(call => String(call[0])).join('')
+      expect(warning).toContain(`${NAME}: warning: 5 entries did not activate`)
+      expect(warning).toContain('import-failure (./missing.mjs): failed to import')
+      expect(warning).toContain('SyntaxError: Unexpected token')
+      expect(warning).toContain('sync apply failure')
+      expect(warning).toContain('async apply failure')
+      expect(warning).toContain('waiting for service: neverProvided')
+      expect(readFileSync(configPath, 'utf8')).toBe(config)
+    } finally {
+      write.mockRestore()
+      await ctx?.fiber.dispose()
+    }
   })
 
-  it('appends the deepest cause with its original stack to the load failure', async () => {
+  it('disposes successful entries and rejects when a required entry fails', async () => {
     const dir = tmp()
-    writeFileSync(join(dir, 'failing.mjs'), [
-      'export function apply() {',
-      "  const failure = new Error('pinned activation failure')",
-      "  failure.stack = 'Error: pinned activation failure\\n    at failing-fixture'",
-      '  throw failure',
+    let disposed = false
+    writeFileSync(join(dir, 'good.mjs'), [
+      'export function apply(ctx) {',
+      '  globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = false',
+      '  ctx.effect(() => () => { globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = true })',
       '}',
       '',
     ].join('\n'))
-    writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n  name: ./failing.mjs\n')
+    writeFileSync(join(dir, 'required-failure.mjs'), 'export function apply() { throw new Error("required apply failure") }\n')
+    writeFileSync(join(dir, 'cordis.yml'), [
+      '- id: good',
+      '  name: ./good.mjs',
+      '- id: webserver',
+      '  name: ./required-failure.mjs',
+      '',
+    ].join('\n'))
+
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
-      String.raw`\./failing\.mjs: Error: pinned activation failure\n`,
-      String.raw` {4}at failing-fixture$`,
-    ].join('')))
+      'plugin tree failed to load: required startup failure: 1 entry did not activate',
+      String.raw`webserver \(\.\/required-failure\.mjs\):`,
+      'required apply failure',
+    ].join(String.raw`[\s\S]*`)))
+    disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false
+    delete (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__
+    expect(disposed).toBe(true)
   })
 
   it('falls back to the deepest cause message when its stack was erased', async () => {
@@ -901,15 +930,6 @@ describe('boot', () => {
     }
   })
 
-  it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
-    const dir = tmp()
-    writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
-    writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n  name: ./waiting.mjs\n')
-    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
-      `${NAME}: 1 entry did not activate`,
-      './waiting.mjs: pending (waiting for service: neverProvided)',
-    ].join('\n'))
-  })
 })
 
 describe('addHarnessSourceSection', () => {

+ 2 - 2
packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts

@@ -73,7 +73,7 @@ function eventTypes(captures: OtlpCapture[]): string[] {
 }
 
 describe('session-telemetry-otel through the production headless profile', () => {
-  it('rejects FULL before any session can be uploaded', async () => {
+  it('continues the headless task without telemetry when FULL is rejected', async () => {
     const { stdout, stderr } = await runLoaderSmoke({
       label: 'session-telemetry-otel rejected FULL loader smoke',
       tempDirPrefix: 'telemetry-otel-full-e2e-',
@@ -82,8 +82,8 @@ describe('session-telemetry-otel through the production headless profile', () =>
       configPath,
       tsconfigPath: repoTsconfig,
       env: { DSH_TELEMETRY_E2E_MODE: 'FULL' },
-      expectedExitCode: 1,
     })
+    expect(stdout + stderr).toContain('warning: 1 entry did not activate')
     expect(stdout + stderr).toContain('FULL')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)