fix review findings: harden the app bins + built-bin smokes, arch-exception doc, snapshot fixture-guard

BLOCKER — the published lib/bin.js (stdio + acp) was exercised only via tsx
(demo:* / the src/bin.ts smokes); the built artifact under plain `node` was
unguarded. Root-cause on the BUILT bin:
  1. Settle race: boot() returned once loader.create() registered the include
     ENTRY, but the include loads its child plugins asynchronously — so boot()
     (and main()) resolved while the app plugins (stdin reader, agent loop, ACP
     bridge) were still mounting. A CLI with no attached handles yet exits 0
     silently, and a load error surfaces as an unhandled rejection AFTER boot.
     Fix: `await ctx.loader.await()` after create() — settle the whole tree.
  2. Config-path robustness: hand the include the config's ABSOLUTE file:// URL
     so resolution never depends on ctx.baseUrl / can never fall back to cwd.
Both bins fixed identically. NOTE: the cordis Loader resolves a config's bare
plugin specifiers via its internal module loader, active only under
`node --expose-internals`; the bin cannot add a node flag itself, so this is
documented in the bin JSDoc + both package READMEs (the demos already comply).
The repo `examples/*/cordis.yml` are tsx-only artifacts (workspace plugins
resolve through the tsconfig paths map, not node_modules), so they are not a
valid plain-node bin target — the smokes use a real-install-shaped temp dir.

Fail loud on a load failure: boot() previously exited 0 SILENTLY when a config
path's directory does not exist — the include plugin fails to IMPORT, the cordis
Loader catches+LOGS it and leaves the entry with no fiber (no rejection), and
`loader.await()` does not rethrow (EntryTree.await uses Promise.allSettled). Fix:
boot() now calls assertEntriesLoaded(ctx) after the tree settles and throws on
any entry with no fiber, so a typo'd config dir exits non-zero with a clear
message. main() also installs an unhandledRejection guard (installFailLoud) that
replaces Node's stack dump with a single labelled stderr line for the
companion case (a missing config FILE in a real dir, whose include-init throw
surfaces as a rejection Node already exits non-zero on). Regression tests added
to both built-bin smokes (missing dir + missing file → non-zero exit + stderr);
verified the missing-dir test fails on the pre-fix bin.

Built-bin smokes (the reviewer's ask): packages/ui/{stdio,acp}-agent/tests/
built-bin.e2e.ts run the REAL lib/bin.js under `node` (NOT tsx) in a temp
consumer dir, asserting the stdio echo round-trip / the acp initialize response
+ stdout purity, plus the fail-loud cases above. They build-gate (skip if lib/
absent) and run in a new ci.yml step after the build.

Issue 2 — packages/README.md + docs/architecture.md said "plugins depend on
interfaces, never on the concrete loop", but dsh-agent-core imports the concrete
dsh-agent-loop. Scope the rule to EXTENSION plugins and carve out the sanctioned
COMPOSITION/bundle exception (dsh-agent-core composes the concrete spine); note
it in the implemented RFC too.

Issue 3 — examples/acp-agent/tests/acp.snapshot.ts fixture-guard claimed
no-model scenarios need no session.jsonl, but runScenario() always boots
llm-replay with the session.jsonl path and loadReplayScript() throws when it is
absent. Require session.jsonl for ALL scenarios (no-model ones ship a
header-only fixture) and rewrite the comment to match reality.
This commit is contained in:
Tianyi Cui
2026-06-21 15:13:57 +08:00
parent 9e86fd995c
commit 3567808171
12 changed files with 531 additions and 32 deletions

View File

@@ -59,10 +59,70 @@ function loadEnv(): void {
}
/**
* Boot the Loader against `absoluteConfigPath`. `baseUrl` is pinned to the
* config's directory and the include gets only the basename, so the config's
* relative plugin/include paths resolve as the upstream `cordis` bin does.
* Returns the root context.
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path the entry-tree check below cannot: when the include's
* `[Service.init]` throws (e.g. a config FILE missing in a real directory), the
* cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()`
* resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses
* `Promise.allSettled`, which swallows rejections). Node's default handler
* already exits non-zero on an unhandled rejection, so this does not change the
* exit code; it replaces the noisy stack dump with a single labelled line (on
* STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`.
* Install before `boot()`.
*/
export function installFailLoud(): void {
process.on('unhandledRejection', (err: unknown) => {
process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
process.exit(1)
})
}
/**
* After the tree settles, assert every loader entry actually started. This is
* the load-bearing guard against the SILENT-exit-0 bug: a plugin module that
* fails to IMPORT (e.g. a config path in a non-existent directory) is caught and
* only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no
* `fiber` and producing no rejection — so the process would otherwise exit 0. A
* started entry has a `fiber`; throw on any entry still missing one so `boot()`
* rejects.
*/
function assertEntriesLoaded(ctx: Context): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `absoluteConfigPath`. The include is handed the
* config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on
* `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to
* the cwd. `baseUrl` is still pinned to the config's directory so the config's
* OWN relative plugin/include paths resolve against it. Returns the root context
* once the whole tree has settled.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
* the include ENTRY is registered, but the include then loads its child plugins
* asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP
* bridge is still mounting — the process would have no stdin handle attached yet
* and could exit 0 silently. Awaiting keeps the process alive until the bridge
* is up.
*
* `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses
* `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails
* to IMPORT leaves an entry with no fiber, caught here by
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS
* surfaces as an unhandled rejection caught by {@link installFailLoud} (installed
* by `main()` before this runs). Together any load failure exits non-zero.
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
* resolved by the cordis Loader's internal module loader, which is only active
* under `node --expose-internals`. The `demo:acp` script runs under tsx (whose
* tsconfig `paths` map resolves the workspace plugins instead), but a consumer
* running the built bin under plain node must pass `--expose-internals` so the
* Loader resolves the config's plugins from the config directory rather than
* relative to its own module.
*/
export async function boot(absoluteConfigPath: string): Promise<Context> {
const ctx = new Context()
@@ -70,19 +130,23 @@ export async function boot(absoluteConfigPath: string): Promise<Context> {
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: { path: `./${basename(absoluteConfigPath)}` },
config: { path: pathToFileURL(absoluteConfigPath).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx)
return ctx
}
/**
* Entry point. Selects the config (snapshot-aware), loads `.env` outside replay,
* boots, and — in a snapshot run — disposes the context on stdin EOF so the
* session log is fully flushed before exit and the harness's `waitForExit`
* resolves. In a normal editor session stdin stays open for the connection's
* lifetime (the editor kills the process), so the EOF handler never fires.
* Entry point. Installs the fail-loud guard, selects the config (snapshot-aware),
* loads `.env` outside replay, boots, and — in a snapshot run — disposes the
* context on stdin EOF so the session log is fully flushed before exit and the
* harness's `waitForExit` resolves. In a normal editor session stdin stays open
* for the connection's lifetime (the editor kills the process), so the EOF
* handler never fires.
*/
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
installFailLoud()
const snapshotMode = process.env.DSH_SNAPSHOT
const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode)
if (snapshotMode !== 'replay') loadEnv()