fix(cli): group startup failures and pending services

This commit is contained in:
turtle1999 committed 2026-09-16 19:04:12 +08:00
1 parent f756097108
commit 38fb1a11f9
13 files changed
+175 -70

No files matched your search

@@ -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: e009e66ead25ef0a5e6001d33663e32bc04d19d2
2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4
2026-09-09-consumer-owned-startup-strictness.md: b8c046e33495b0334593362fd083fefd9c7343c0
2026-09-09-consumer-owned-startup-strictness.zh.md: 81c1fa4a6e2af620a5c3fd1b91957cf508230834
@@ -28,11 +28,11 @@ This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transpor
## Consequences
Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root.
Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures combine every inactive entry into one diagnostic, separating failed plugins from pending services and marking required entries. `StartupError` retains the original failures as its cause after app-boot disposes the root. The CLI prints its message once and exits with code 1, avoiding duplicate wrapper stacks while preserving plugin stacks, nested causes, and aggregate members. Unrelated exceptions remain unhandled.
## 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.
App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping and preservation of original error objects. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
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.
@@ -28,11 +28,11 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
## 后果
稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。
稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 将所有 inactive entry 合并到一份诊断中,区分失败插件与等待服务的插件,并标记 required entry。App-boot 拆卸 root 后,`StartupError` 仍以 cause 保留原始失败。CLI 仅输出其消息一次,并以退出码 1 结束,避免重复的包装堆栈,同时保留插件堆栈、嵌套原因和聚合错误成员。其他异常继续作为未处理异常抛出。
## 测试
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` 无法激活时以非零码退出,且不报告就绪。
App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组和原始错误对象的保留行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
[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)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
+8 -2
View File
@@ -8,7 +8,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
@@ -62,5 +62,11 @@ export async function runCli(): Promise<void> {
}
if (import.meta.main) {
await runCli()
try {
await runCli()
} catch (error) {
if (!(error instanceof StartupError)) throw error
process.stderr.write(`${error.message}\n`)
process.exitCode = 1
}
}
@@ -358,9 +358,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('required startup failure')
expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError')
expect(stderr).toContain('startup failed:')
expect(stderr).toContain('sdk-jsonrpc-server (required)\n Package: @deepseek-ai/dsh-sdk-jsonrpc-server\n SyntaxError')
expect(stderr).toContain('sometimes')
} finally {
await rm(root, { recursive: true, force: true })
@@ -210,7 +210,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
: 'inject: [webProbeMissingRequiredService]'
const diagnostic = failure === 'disabled expression'
? 'disabled expression failed: SyntaxError'
: 'pending (waiting for service: webProbeMissingRequiredService)'
: 'webProbeMissingRequiredService'
writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n ${patch}\n`)
try {
const result = await execa(process.execPath, [
@@ -238,8 +238,9 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.signal).toBeUndefined()
expect(result.exitCode).toBe(1)
expect(result.stdout).not.toContain('dsh web: http://')
expect(result.stderr).toContain('required startup failure')
expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`)
expect(result.stderr).toContain('startup failed:')
expect(result.stderr).toContain(`${id} (required)`)
expect(result.stderr).toContain(diagnostic)
expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
} finally {
rmSync(fixture.root, { recursive: true, force: true })
@@ -289,8 +290,15 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.signal).toBeUndefined()
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')
expect(result.stderr).toContain('startup failed:')
expect(result.stderr).toContain('dsh: startup failed: 2 required plugins did not activate')
expect(result.stderr).toContain('Failed plugins (1):')
expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver')
expect(result.stderr).toContain('Plugins waiting for services (')
expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u)
expect(result.stderr).toContain('at Server.setupListenHandle')
expect(result.stderr.match(/EADDRINUSE/gu)).toHaveLength(1)
expect(result.stderr).not.toMatch(/dsh: warning:|\[cause\]|at boot \(|at runCli \(|Node\.js v/u)
} finally {
await new Promise<void>((resolve, reject) => {
blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })
@@ -178,7 +178,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
const result = await app.child
exit(result, 1)
expect(result.stdout).not.toContain('dsh web: http://')
expect(result.stderr).toContain('required startup failure')
expect(result.stderr).toContain('startup failed:')
expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n')
} else {
await app.serves()
@@ -213,7 +213,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
} 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')
expect(app.stderr()).not.toContain('startup failed:')
} finally {
const result = await app.close()
exit(result, 0)
@@ -288,7 +288,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
const app = start(f)
try {
await app.serves()
expect(app.stderr()).not.toContain('required startup failure')
expect(app.stderr()).not.toContain('startup failed:')
expect(app.stderr()).not.toContain('failed to import')
} finally { exit(await app.close(), 0) }
})
@@ -323,7 +323,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
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.stderr()).not.toContain('startup failed:')
expect(app.events()).not.toContain('witness dispose 1\n')
expect(existsSync(f.serverUrl)).toBe(false)
writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
@@ -413,7 +413,7 @@ export function apply(ctx, config) {
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')
expect(app.stderr()).not.toContain('startup failed:')
writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{
id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' },
}])}\n`)
+2 -2
View File
@@ -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: cf0d67fb14633cfdf9a30754125b3e08df2d0496
README.zh.md: dab003c581ba5744893e612eeee812b56c25f1a2
README.md: 970b658cd56843762686dada06a68e017167f3d5
README.zh.md: b5a42d0f668e500a1573e0df5e74dfa06e113bf4
+2 -2
View File
@@ -69,7 +69,7 @@ Before you boot, you can print the exact configuration the app will mount: the d
Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation.
After the Loader settles, app-boot 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.
After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and exits with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
| Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR |
|---|---|---|---|
@@ -116,7 +116,7 @@ This section explains how the outcomes above are realized and points at the code
- **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned generation in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection.
- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
- **Two-stage failure labels.** Outside startup audit failures, `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
### Helper behavior
+2 -2
View File
@@ -69,7 +69,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。
Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的 required 条目无法激活,则拒绝启动。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
| 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR |
|---|---|---|---|
@@ -116,7 +116,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
- **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 generation。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。
- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
- **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
### Helper 行为
+67 -30
View File
@@ -253,7 +253,7 @@ export async function reconcileProfilePatches(
const entry = bootstrapIncludes.get(ctx)
if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
const previousFailures = (await inactiveEntries(ctx)).map(failure => ({
...failure, fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options),
...failure, diagnostic: inactiveDiagnostic(failure), fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options),
}))
// Removed entries leave the Loader store before their async disposers finish.
const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{
@@ -266,12 +266,12 @@ export async function reconcileProfilePatches(
const failures = await inactiveEntries(ctx)
const introduced = failures.filter(failure => requiredIds.includes(failure.entry.options.id) || !previousFailures.some(previous =>
previous.entry === failure.entry && previous.fiber === failure.entry.fiber
&& previous.options === JSON.stringify(failure.entry.options) && previous.diagnostic === failure.diagnostic))
if (introduced.length > 0) throw new Error(activationDiagnostic(binName, 'warning', introduced).trimEnd())
&& previous.options === JSON.stringify(failure.entry.options) && previous.diagnostic === inactiveDiagnostic(failure)))
if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced).trimEnd())
for (const [index, result] of results.entries()) {
if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason
}
return failures.map(failure => failure.diagnostic)
return failures.map(inactiveDiagnostic)
}
/**
@@ -719,10 +719,14 @@ function formatActivationError(error: unknown): string {
interface InactiveEntry {
/** Loader entry used to identify the bootstrap Include and required ids. */
entry: Entry
/** Complete diagnostic beginning with the entry id and module specifier. */
diagnostic: string
/** Activation errors and missing services remain distinct for presentation. */
outcome: { kind: 'failed'; error: unknown; phase?: string }
| { kind: 'pending'; missing: string[] }
}
/** Startup audit failure whose message is a complete CLI diagnostic; cause retains plugin errors. */
export class StartupError extends Error {}
/**
* Collect Loader activation failures and disabled-expression errors. Failed
* fibers are awaited to recover their recorded rejection reason and coalesce
@@ -732,16 +736,15 @@ async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
const failures: InactiveEntry[] = []
const rejectionReasons: unknown[] = []
for (const entry of ctx.loader.entries()) {
const subject = `${entry.options.id} (${entry.options.name})`
try {
if (entry.disabled) continue
} catch (error) {
failures.push({ entry, diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}` })
failures.push({ entry, outcome: { kind: 'failed', error, phase: 'disabled expression failed' } })
continue
}
const fiber = entry.fiber
if (fiber === undefined) {
failures.push({ entry, diagnostic: `${subject}: failed to import` })
failures.push({ entry, outcome: { kind: 'failed', error: 'failed to import' } })
continue
}
const state = fiber.state
@@ -751,7 +754,7 @@ async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
await fiber.await()
} catch (error) {
rejectionReasons.push(error)
failures.push({ entry, diagnostic: `${subject}: ${formatActivationError(error)}` })
failures.push({ entry, outcome: { kind: 'failed', error } })
}
continue
}
@@ -759,40 +762,75 @@ async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
failures.push({
entry,
diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
outcome: { kind: 'pending', missing },
})
} else {
failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` })
failures.push({ entry, outcome: { kind: 'failed', error: `fiber state ${String(state)}` } })
}
}
if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
return failures
}
/** Render an inactive-entry diagnostic with a count and severity label. */
/** Render one failed plugin's original error and activation phase. */
function failureDetail(outcome: Extract<InactiveEntry['outcome'], { kind: 'failed' }>): string {
return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error)}`
}
/** Render optional-only warnings without changing startup policy. */
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`
return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(inactiveDiagnostic).join('\n')}\n`
}
/** Stable per-entry text for reload comparisons and optional warnings. */
function inactiveDiagnostic({ entry, outcome }: InactiveEntry): string {
const detail = outcome.kind === 'failed' ? failureDetail(outcome)
: `pending (waiting for ${outcome.missing.length === 1 ? 'service' : 'services'}: ${outcome.missing.join(', ') || 'unknown'})`
return `${entry.options.id} (${entry.options.name}): ${detail}`
}
/** Group startup failures and pending services, marking every required entry. */
function startupDiagnostic(binName: string, failures: readonly InactiveEntry[], required: ReadonlySet<Entry>): string {
const lines = [`${binName}: startup failed: ${String(required.size)} required ${required.size === 1 ? 'plugin' : 'plugins'} did not activate`]
const failed = failures.flatMap(({ entry, outcome }) => outcome.kind === 'failed' ? [{ entry, outcome }] : [])
const pending = failures.flatMap(({ entry, outcome }) => outcome.kind === 'pending' ? [{ entry, outcome }] : [])
const label = (entry: Entry): string => `${entry.options.id}${required.has(entry) ? ' (required)' : ''}`
if (failed.length > 0) {
lines.push('', `Failed plugins (${String(failed.length)}):`)
for (const { entry, outcome } of failed) {
lines.push(` ${label(entry)}`, ` Package: ${entry.options.name}`)
lines.push(...failureDetail(outcome).split('\n').map(line => ` ${line}`))
}
}
if (pending.length > 0) {
const width = Math.max('Plugin'.length, ...pending.map(({ entry }) => label(entry).length)) + 2
lines.push('', `Plugins waiting for services (${String(pending.length)}):`, ` ${'Plugin'.padEnd(width)}Missing services`)
for (const { entry, outcome } of pending) {
lines.push(` ${label(entry).padEnd(width)}${outcome.missing.join(', ') || 'unknown'}`)
}
}
return lines.join('\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.
* inactive entries join that failure diagnostic, or produce one warning when
* no required entry failed and leave successful siblings running.
* Required ids absent from the tree, and disabled required entries, are ignored.
* A throwing disabled expression is an entry failure, not a disabled entry.
* The bootstrap Include must activate so unreadable or invalid root config is fatal.
* @param ctx - the settled context whose Loader entries to audit.
* @param binName - the diagnostic prefix on optional-entry warnings.
* @param binName - the prefix on startup diagnostics.
* @param warn - sink for optional-entry warnings.
* @returns after optional warnings if required startup checks pass.
* @throws when the bootstrap Include or a required entry is inactive or its disabled expression throws.
* @throws {@link StartupError} when the bootstrap Include or a required entry is inactive or its disabled expression throws;
* its message includes optional failures too.
*/
export async function auditStartupEntries(
ctx: Context,
@@ -800,17 +838,14 @@ export async function auditStartupEntries(
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 = failure.entry === bootstrapIncludes.get(ctx)
|| requiredStartupEntryIds.has(failure.entry.options.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())
const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx)
|| requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
if (required.size > 0) {
throw new StartupError(startupDiagnostic(binName, failures, required), {
cause: new AggregateError(failures.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : []), 'Plugin activation failures'),
})
}
if (failures.length > 0) warn(activationDiagnostic(binName, failures))
}
/**
@@ -838,7 +873,8 @@ export async function auditStartupEntries(
* complete plugin set.
* @returns the root context after the initial startup audit, or as soon as a
* surface disposed the tree while startup was still in flight.
* @throws a labelled error after disposing the partial context — `host
* @throws {@link StartupError} for an inactive required entry, including all inactive plugins in its message;
* otherwise a labelled error after disposing the partial context — `host
* preparation failed` when `prepare` threw before any config-tree entry
* mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
* diagnostic traversal without replacing the original cause.
@@ -880,6 +916,7 @@ export async function boot(
// fiber.ts hardening) and a repeated call returns the settled single-shot
// result, so this await cannot reject and replace `cause`.
await ctx.fiber.dispose()
if (cause instanceof StartupError) throw cause
const detail = cause instanceof Error ? cause.message : String(cause)
// A wrapper can carry an activation error whose original stack names the failed plugin.
let deepest: unknown = cause
+59 -13
View File
@@ -6,7 +6,7 @@ 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, auditStartupEntries, boot,
addHarnessSourceSection, auditStartupEntries, boot, StartupError,
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
@@ -587,7 +587,7 @@ describe('auditStartupEntries', () => {
}]), NAME, warn)
const detail = `${id} (./plugin.mjs): disabled expression failed: ${error.stack!}`
if (required) {
await expect(result).rejects.toThrow(`required startup failure: 1 entry did not activate\n${detail}`)
await expect(result).rejects.toThrow(` ${id} (required)\n Package: ./plugin.mjs\n disabled expression failed: ${error.stack!.replaceAll('\n', '\n ')}`)
expect(warn).not.toHaveBeenCalled()
} else {
await expect(result).resolves.toBeUndefined()
@@ -670,18 +670,64 @@ describe('auditStartupEntries', () => {
expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1')
})
it.each(requiredIds)('rejects required %s failures after warning about optional failures', async (id) => {
it.each(requiredIds)('combines required %s and optional failures without a separate warning', async (id) => {
const warn = vi.fn()
const requiredError = new Error('address already in use')
const optionalError = new Error('todo unavailable')
await expect(auditStartupEntries(ctxWith([
const error = await auditStartupEntries(ctxWith([
{ fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } },
{ 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',
`${id} (./required.mjs): ${requiredError.stack!}`,
].join('\n'))
expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
]), NAME, warn).catch((error: unknown) => error)
expect(error).toBeInstanceOf(StartupError)
expect((error as Error).message).toContain(`${NAME}: startup failed: 1 required plugin did not activate`)
expect((error as Error).message).toContain(` ${id} (required)\n Package: ./required.mjs`)
expect((error as Error).message).toContain(' tool-todo\n Package: @deepseek-ai/dsh-tool-todo')
expect(((error as Error).cause as AggregateError).errors).toEqual([requiredError, optionalError])
expect(warn).not.toHaveBeenCalled()
})
it('retains nested and shared errors in a fatal diagnostic without duplicating them', async () => {
const leaf = new Error('leaf failure')
leaf.stack = 'Error: leaf failure\n at plugin.mjs:1:2'
const aggregate = new AggregateError([leaf, 'plain failure'], 'activation failed', { cause: leaf })
aggregate.stack = 'AggregateError: activation failed\n at plugin.mjs:3:4'
const error = await auditStartupEntries(ctxWith([
{ fiber: fiber(3, aggregate), options: { id: 'webserver', name: './plugin.mjs' } },
]), NAME, vi.fn()).catch((error: unknown) => error)
expect((error as Error).message).toContain('AggregateError: activation failed')
expect((error as Error).message).toContain(' Error: leaf failure\n at plugin.mjs:1:2')
expect((error as Error).message.match(/leaf failure/gu)).toHaveLength(1)
expect((error as Error).message).toContain(' plain failure')
expect(((error as Error).cause as AggregateError).errors).toEqual([aggregate])
})
it('groups original failure stacks and pending services in one startup diagnostic', async () => {
const original = new Error('listen EADDRINUSE: address already in use 127.0.0.1:3080')
original.stack = `${original.name}: ${original.message}\n at Server.listen (node:net:1:2)`
const warn = vi.fn()
const error = await auditStartupEntries(ctxWith([
{ fiber: fiber(0, undefined, { webServer: {} }), options: { id: 'web-runtime', name: './web.mjs' } },
{ fiber: fiber(3, original), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } },
{ fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
{ fiber: fiber(0), options: { id: 'unknown', name: './unknown.mjs' } },
]), NAME, warn).catch((error: unknown) => error)
expect(error).toBeInstanceOf(StartupError)
expect((error as Error).message).toMatchInlineSnapshot(`
"dsh-test-bin: startup failed: 2 required plugins did not activate
Failed plugins (1):
webserver (required)
Package: @deepseek-ai/dsh-host-webserver
Error: listen EADDRINUSE: address already in use 127.0.0.1:3080
at Server.listen (node:net:1:2)
Plugins waiting for services (3):
Plugin Missing services
web-runtime webServer
connection (required) webRuntime
unknown unknown"
`)
expect(warn).not.toHaveBeenCalled()
})
it('rejects a required entry pending on an injected service', async () => {
@@ -689,7 +735,7 @@ describe('auditStartupEntries', () => {
fiber: fiber(0, undefined, { headlessStartup: {} }),
options: { id: 'headless-runner', name: '@deepseek-ai/dsh-headless' },
}]), NAME, vi.fn())).rejects.toThrow(
'headless-runner (@deepseek-ai/dsh-headless): pending (waiting for service: headlessStartup)',
'headless-runner (required) headlessStartup',
)
})
})
@@ -960,7 +1006,7 @@ describe('boot', () => {
['import', undefined, '', 'failed to import'],
['config schema', 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate() { return { issues: [{ message: "schema failure" }] } } } }\nexport function apply() {}\n', '', 'schema failure'],
['config expression', 'export function apply() {}\n', ' config: { value: !!js "JSON.parse(\'invalid\')" }\n', 'SyntaxError'],
['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'required startup failure: 1 entry did not activate\nwebserver (./required.mjs): disabled expression failed: SyntaxError'],
['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'disabled expression failed: SyntaxError'],
['sync apply', 'export function apply() { throw new Error("sync failure") }\n', '', 'sync failure'],
['async apply', 'export async function apply() { await Promise.resolve(); throw new Error("async failure") }\n', '', 'async failure'],
['missing dependency', 'export const inject = ["missingRequiredService"]\nexport function apply() {}\n', '', 'missingRequiredService'],
@@ -995,8 +1041,8 @@ describe('boot', () => {
].join('\n'))
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
'plugin tree failed to load: required startup failure: 1 entry did not activate',
String.raw`webserver \(\.\/required-failure\.mjs\):`,
'startup failed: 1 required plugin did not activate',
String.raw`webserver \(required\)`,
'required apply failure',
].join(String.raw`[\s\S]*`)))
disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false
@@ -331,6 +331,15 @@ describe('Loader entry disabled interpolation', () => {
})
describe('profile reconciliation settlement', () => {
it('retains unchanged import diagnostics across profile reconciliation', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const patches = [{ insert: [{ id: 'missing-plugin', name: './missing.mjs' }] }]
const ctx = await boot(NAME, join(dir, 'cordis.yml'), patches)
onTestFinished(() => ctx.fiber.dispose())
expect(await reconcileProfilePatches(ctx, patches, NAME)).toEqual(['missing-plugin (./missing.mjs): failed to import'])
})
it('rejects a context without the launcher root Include', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())