diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs new file mode 100644 index 0000000000..16e5858045 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs @@ -0,0 +1,6 @@ +/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */ +export function apply() { + const failure = new Error('startup activation snapshot failure') + failure.stack = 'Error: startup activation snapshot failure\n at activation-error-fixture' + throw failure +} diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml new file mode 100644 index 0000000000..2738e4a924 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml @@ -0,0 +1,2 @@ +- id: activation-error + name: ./activation-error.mjs diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 4217f24228..06d42ac9cc 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -33,6 +33,8 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) +const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) +const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) @@ -167,6 +169,32 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('prints the original Loader activation error through the assembled one-shot app', async () => { + const label = 'headless startup activation error snapshot' + let failure: unknown + try { + await runLoaderSmoke({ + label, + tempDirPrefix: 'headless-snapshot-startup-error-', + binScript, + configPath: startupFailureConfigPath, + binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], + tsconfigPath, + }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(Error) + const message = (failure as Error).message + const prefix = `${label} exited 1. stdout:\n` + const stderrMarker = '\nstderr:\n' + expect(message.startsWith(prefix)).toBe(true) + const stderrAt = message.indexOf(stderrMarker, prefix.length) + expect(stderrAt).toBeGreaterThanOrEqual(prefix.length) + expect(message.slice(prefix.length, stderrAt)).toBe('') + await expect(message.slice(stderrAt + stderrMarker.length)).toMatchFileSnapshot(startupFailureExpected) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('retries a transient provider failure through the one-shot app', async () => { const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry') const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl') diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt new file mode 100644 index 0000000000..5896d03464 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -0,0 +1,3 @@ +dsh-cli-demo: dsh-cli-demo: 1 entry did not activate +./activation-error.mjs: Error: startup activation snapshot failure + at activation-error-fixture diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index f7488ff2f4..694295e7f2 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,9 @@ interface PkgMeta { immediately: boolean } +/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */ +const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch' + /** Missing built client export, retained as structured data for activation-error grouping. */ class MissingClientBundleError extends Error { constructor( @@ -66,7 +69,11 @@ class MissingClientBundleError extends Error { cause: unknown, ) { super( - `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`, + [ + `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`, + ` package: ${packageName}`, + ` path: ${clientPath}`, + ].join('\n'), { cause }, ) } @@ -80,7 +87,7 @@ class ClientPackageCompositionError extends AggregateError { const packageNoun = failures.length === 1 ? 'package' : 'packages' const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] if (missingBundles.length > 0) { - lines.push(' client packages requiring a build before source launch:') + lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`) for (const error of missingBundles) { lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) } @@ -353,7 +360,13 @@ export class ClientModuleHostService extends Service { return meta } - /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */ + /** + * Read the activation-time bundle revision. + * @param pkgName - package that declares the client bundle. + * @param clientPath - absolute path of the built client artifact. + * @returns the bundle content's short hash for use as its revision. + * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged. + */ private initialBundleRevision(pkgName: string, clientPath: string): string { try { return shortHash(readFileSync(clientPath)) diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index bc829ab7db..3eb99c0ead 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -61,7 +61,7 @@ describe('client bundle activation', () => { const secondPath = writePackage(secondName) expect(() => construct([firstName, secondName])).toThrow([ 'client-modules: 2 client packages failed to compose:', - ' client packages requiring a build before source launch:', + ' client bundles not found; run `pnpm run build` before launch:', ` - package: ${firstName}`, ` path: ${firstPath}`, ` - package: ${secondName}`, @@ -79,7 +79,9 @@ describe('client bundle activation', () => { } catch (error) { thrown = error } + expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:') + expect(String(thrown)).toContain(' other failures:') expect(String(thrown)).toContain('EISDIR') - expect(String(thrown)).not.toContain('requiring a build before source launch') + expect(String(thrown)).not.toContain('pnpm run build') }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 4126ecee12..2cbbfa4f4c 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/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/ui/app-boot/README.md -README.md: 7c4b258de4e5cc837ea7fa2aa8448feb421c3661 -README.zh.md: b1b12217792217bd1b29bd0752dd41afd369d5a6 +README.md: 55211988a7687ba52f13d30931e042f0823e2526 +README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7c4b258de4..55211988a7 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,14 +10,14 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | -| `assertEntriesActivated(ctx, binName)` | Await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | +| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | | `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 | -The guards preserve two Loader failure classes. 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. `installFailLoud` remains the process guard for rejections that escape after boot. 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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b1b1221779..92e2ba5609 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -10,14 +10,14 @@ | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | -| `assertEntriesActivated(ctx, binName)` | Loader 结算后等待每个已启用条目;抛出的异常包含每个失败插件的原始 stack,或每个 pending 插件尚未解析的服务 | +| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护保留 Loader 的两类故障。插件导入失败会留下没有 fiber 的条目,`assertEntriesLoaded` 将它转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下 failed fiber,因为 `loader.await()` 只结算生命周期任务、不向外传播该错误;`assertEntriesActivated` 显式等待 fiber,并在启动 rejection 中包含原始 stack。`installFailLoud` 仍负责拦截 boot 之后逃逸的 rejection。 +Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 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()`。 diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 9a80730a1a..8edbb264cd 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -300,31 +300,28 @@ describe('boot', () => { await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) }) - it('rejects a settled tree with a pending inject and names every missing service', async () => { + it('reports an activation error from a real Loader fiber instead of its numeric state', async () => { const dir = tmp() - writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n") + 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') writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)') - }) - - it('uses singular diagnostics for one missing pending dependency', () => { - const ctx = { - loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] }, - get: () => undefined, - } as unknown as Context - expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)') - }) - - it('reports unknown pending dependencies and unexpected fiber states', () => { - const entries = [ - { disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } }, - { disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } }, - ] - const ctx = { - loader: { entries: () => entries }, - get: () => undefined, - } as unknown as Context - expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`) + 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')) }) }) diff --git a/vendor/README.md b/vendor/README.md index b140c057ab..8b34e14b47 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,6 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +10. **`loader/src/config/entry.ts` activation observer**: observes both fulfillment and rejection before notifying Loader reflection. Upstream's `fiber.await().finally(...)` leaves a rejected derived promise unhandled when plugin activation fails, so Node can terminate before the host reads the original error from the fiber. Covered through the real Loader by `packages/ui/app-boot/tests/app-boot.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..a8821185c2 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -160,10 +160,11 @@ export class Entry { } finally { this._initTask = undefined } - this.fiber?.await().finally(() => { + const notify = () => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) - }) + } + void this.fiber?.await().then(notify, notify) } private async _init() {