fix(app-boot): preserve published loader composition
This commit is contained in:
@@ -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/ui/app-boot/README.md
|
||||
README.md: 55211988a7687ba52f13d30931e042f0823e2526
|
||||
README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6
|
||||
README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
|
||||
README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42
|
||||
|
||||
@@ -17,9 +17,9 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot.
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
|
||||
This 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; built consumers continue to use plain Node package resolution.
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 rejection。
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
|
||||
@@ -160,16 +160,46 @@ export interface FailLoudProcess {
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
|
||||
// exact reasons already folded into the boot diagnostic visible through the
|
||||
// next process rejection checkpoint so the process guard can coalesce them.
|
||||
const assembledActivationRejections = new Map<unknown, number>()
|
||||
|
||||
function retainAssembledRejection(reason: unknown): void {
|
||||
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function releaseAssembledRejection(reason: unknown): void {
|
||||
const count = assembledActivationRejections.get(reason)
|
||||
if (count === undefined || count === 1) {
|
||||
assembledActivationRejections.delete(reason)
|
||||
} else {
|
||||
assembledActivationRejections.set(reason, count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
|
||||
for (const reason of reasons) retainAssembledRejection(reason)
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
} finally {
|
||||
for (const reason of reasons) releaseAssembledRejection(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
|
||||
* the returned function removes the handler.
|
||||
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
|
||||
* {@link assertEntriesActivated} is ignored during its process checkpoint;
|
||||
* every other rejection remains fatal. Stdout remains untouched for ACP; the
|
||||
* returned function removes the handler.
|
||||
* @param binName - the diagnostic prefix on the fatal-failure line.
|
||||
* @param proc - the process slice to register on; tests inject a fake.
|
||||
* @returns the uninstaller that removes the rejection handler.
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
const handler = (err: unknown): void => {
|
||||
if (assembledActivationRejections.has(err)) return
|
||||
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
proc.exit(1)
|
||||
}
|
||||
@@ -212,17 +242,20 @@ function formatActivationError(error: unknown): string {
|
||||
* @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 when an entry failed to import, rejected during activation, or did not become active.
|
||||
* @throws after one process rejection checkpoint when an entry failed to
|
||||
* import, rejected during activation, or did not become active.
|
||||
*/
|
||||
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
const failures: string[] = []
|
||||
const rejectionReasons: unknown[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const fiber = entry.fiber
|
||||
if (fiber === undefined || entry.disabled) continue
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
rejectionReasons.push(error)
|
||||
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
|
||||
continue
|
||||
}
|
||||
@@ -237,6 +270,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
}
|
||||
}
|
||||
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')}`)
|
||||
}
|
||||
@@ -250,11 +286,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* bootstrap include is therefore statically imported and mounted as the
|
||||
* `cordis:include` builtin, loading through the ambient module pipeline
|
||||
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
|
||||
* config-relative. A missing fiber rejects here; a later init rejection is
|
||||
* rethrown with its original stack by {@link assertEntriesActivated}; later
|
||||
* unhandled rejections remain covered by {@link installFailLoud}. Built bins
|
||||
* need the Loader's native helper for bare plugin specifiers; relative
|
||||
* specifiers do not.
|
||||
* config-relative. The package build embeds Include while leaving Loader
|
||||
* external, so the built include tree and host share one Loader peer. A
|
||||
* missing fiber rejects here; a later init rejection is rethrown with its
|
||||
* original stack by {@link assertEntriesActivated}; 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
|
||||
* (see {@link resolveConfigPath}).
|
||||
|
||||
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
|
||||
uninstallReal()
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before)
|
||||
})
|
||||
|
||||
it('does not report an activation rejection shared by entries in the boot audit', async () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('assembled activation failure')
|
||||
const audit = assertEntriesActivated({
|
||||
loader: {
|
||||
entries: () => ['broken-a', 'broken-b'].map(name => ({
|
||||
options: { name },
|
||||
fiber: {
|
||||
state: 3,
|
||||
inject: {},
|
||||
ctx: { get: () => undefined },
|
||||
await: async () => { throw error },
|
||||
},
|
||||
})),
|
||||
},
|
||||
} as unknown as Context, NAME)
|
||||
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')
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
@@ -300,20 +327,6 @@ describe('boot', () => {
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
|
||||
it('reports an activation error from a real Loader fiber instead of its numeric state', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'broken.mjs'), 'export function apply() { throw new Error("real activation failure") }\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: broken\n name: ./broken.mjs\n')
|
||||
let thrown: unknown
|
||||
try {
|
||||
await boot(NAME, join(dir, 'cordis.yml'))
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(String(thrown)).toContain(`${NAME}: 1 entry did not activate\n./broken.mjs: Error: real activation failure`)
|
||||
expect(String(thrown)).not.toContain('fiber state 3')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
19
packages/ui/app-boot/tsdown.config.ts
Normal file
19
packages/ui/app-boot/tsdown.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Embed Include while keeping Loader external so the built include tree and
|
||||
* app host bind to one Loader peer.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
deps: {
|
||||
alwaysBundle: ['@cordisjs/plugin-include'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user