From 1fd4743154704cff73f70e983af8c10a25ada218 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 17:13:07 +0800 Subject: [PATCH 1/4] test(boot): cover the inventory's runtime attribution of rows and recorded failures --- .../plugin-inventory/tests/inventory.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 6211f1eb33..500ea1cd7b 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -3,6 +3,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' +import { ensurePluginFailures, type ProfileRuntime, type RowOrigin } from '@deepseek-ai/dsh-app-boot' import PluginInventoryGateway from '../src/index.ts' const contexts: Context[] = [] @@ -96,6 +97,40 @@ describe('PluginInventoryGateway', () => { expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false) }) + it('attributes rows to their bundle through the profile runtime and lists recorded failures', async () => { + const { ctx, inventory } = await harness() + // A bare Loader assigns ids; without a root include they are also the tree-wide ids. + const versioned = await ctx.loader.create({ name: 'cordis:active' }) + const bare = await ctx.loader.create({ name: 'cordis:active' }) + const off = await ctx.loader.create({ name: 'cordis:active', disabled: true }) + const origins = new Map([ + [versioned, { trust: 'external', packageName: 'ext', version: '1.2.3' }], + [bare, { trust: 'external', packageName: 'ext' }], + [off, { trust: 'external', packageName: 'ext' }], + ['gone', { trust: 'external', packageName: 'ext' }], + ]) + ctx.provide('profileRuntime', { + originOf: (rowId: string) => origins.get(rowId), + userDisabledRowIds: () => new Set([off]), + } as Partial as never) + const registry = ensurePluginFailures(ctx) + registry.record({ entryId: 'gone', rowId: 'gone', moduleName: 'cordis:throws', groupId: 'bundle/ext', stage: 'apply', message: 'boom' }) + // A record of a row that later mounted rides on the live entry and is not listed twice. + registry.record({ entryId: bare, rowId: bare, moduleName: 'cordis:active', groupId: 'bundle/ext', stage: 'import', message: 'stale' }) + // A failure the runtime cannot attribute belongs to no package. + registry.record({ entryId: 'orphan', rowId: 'orphan', moduleName: 'cordis:throws', groupId: 'bundle/gone', stage: 'apply', message: 'lost' }) + + const { entries } = await inventory.list() + expect(entries).toEqual([ + { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext', version: '1.2.3' } }, + { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'stale' } }, + { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' }, + // A failed row the tree no longer holds is attributed through the runtime. + { entryId: 'gone', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'apply', message: 'boom' } }, + { entryId: 'orphan', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'apply', message: 'lost' } }, + ]) + }) + it('carries each composed preset with root-fiber states mapped to phases', async () => { const { ctx, inventory } = await harness() ctx.provide('agentPresets', { From f43259f9de2244eada97584b7095fe1a939c081c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 17:14:42 +0800 Subject: [PATCH 2/4] refactor(plugins): share the child close settlement with the package probe --- packages/boot/app-boot/src/child-close.ts | 37 +++++++++++++++ packages/boot/app-boot/src/index.ts | 1 + packages/boot/app-boot/src/probe.ts | 58 ++++++++--------------- packages/host/plugin-manager/src/index.ts | 20 ++------ 4 files changed, 64 insertions(+), 52 deletions(-) create mode 100644 packages/boot/app-boot/src/child-close.ts diff --git a/packages/boot/app-boot/src/child-close.ts b/packages/boot/app-boot/src/child-close.ts new file mode 100644 index 0000000000..d800b0c219 --- /dev/null +++ b/packages/boot/app-boot/src/child-close.ts @@ -0,0 +1,37 @@ +/** + * One settlement for a spawned child: the exit code on `close`, the spawn + * failure on `error`, or a timeout that kills the child. Shared by the + * package probe and the plugin manager's pnpm runs, which otherwise each + * wrote the same first-outcome-wins guard. + */ + +import type { ChildProcess } from 'node:child_process' + +/** + * Wait for a child to close, at most `timeoutMs`. + * + * A spawn failure emits `error` and then `close`, and a timeout kill emits + * `close` after the rejection, so the first outcome wins and the later + * events are ignored. + * @param child - the spawned process; the caller consumes its streams. + * @param timeoutMs - how long to wait before killing the child with SIGKILL. + * @param timedOut - builds the rejection reported for a timeout. + * @returns the exit code `close` reported; null when a signal ended the child. + */ +export function awaitChildClose(child: ChildProcess, timeoutMs: number, timedOut: () => Error): Promise { + return new Promise((resolve, reject) => { + let settled = false + const settle = (outcome: () => void): void => { + if (settled) return + settled = true + clearTimeout(timer) + outcome() + } + const timer = setTimeout(() => { + child.kill('SIGKILL') + settle(() => { reject(timedOut()) }) + }, timeoutMs) + child.on('error', (error) => { settle(() => { reject(error) }) }) + child.on('close', (code) => { settle(() => { resolve(code) }) }) + }) +} diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index c7c2923cf7..66469f7c98 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -74,6 +74,7 @@ export { export { ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin, } from './profile-runtime.ts' +export { awaitChildClose } from './child-close.ts' export { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe, type PluginProbeRow, type ProbeOptions, diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index 198c811b2f..c964f94bbf 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -8,6 +8,7 @@ */ import { spawn } from 'node:child_process' +import { awaitChildClose } from './child-close.ts' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -152,45 +153,28 @@ process.stdout.write(JSON.stringify(report)) ` /** Run the child and parse its report. */ -function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise { +async function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS - return new Promise((resolve, reject) => { - const child = spawn( - options.nodeExecutable ?? process.execPath, - ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)], - { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } }, + const child = spawn( + options.nodeExecutable ?? process.execPath, + ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)], + { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } }, + ) + const out: Buffer[] = [] + const err: Buffer[] = [] + child.stdout.on('data', (chunk: Buffer) => out.push(chunk)) + child.stderr.on('data', (chunk: Buffer) => err.push(chunk)) + const code = await awaitChildClose( + child, timeoutMs, () => new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`), + ) + const stdout = Buffer.concat(out).toString('utf8') + try { + return JSON.parse(stdout) as ChildReport + } catch { + throw new Error( + `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`, ) - const out: Buffer[] = [] - const err: Buffer[] = [] - child.stdout.on('data', (chunk: Buffer) => out.push(chunk)) - child.stderr.on('data', (chunk: Buffer) => err.push(chunk)) - // One settlement: a spawn failure emits `error` and then `close`, and a - // timeout kill emits `close` after the rejection below. - let settled = false - const settle = (outcome: () => void): void => { - if (settled) return - settled = true - clearTimeout(timer) - outcome() - } - const timer = setTimeout(() => { - child.kill('SIGKILL') - settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) }) - }, timeoutMs) - child.on('error', (error) => { settle(() => { reject(error) }) }) - child.on('close', (code) => { - settle(() => { - const stdout = Buffer.concat(out).toString('utf8') - try { - resolve(JSON.parse(stdout) as ChildReport) - } catch { - reject(new Error( - `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`, - )) - } - }) - }) - }) + } } const DEFAULT_TIMEOUT_MS = 20_000 diff --git a/packages/host/plugin-manager/src/index.ts b/packages/host/plugin-manager/src/index.ts index ec17f3d9b3..cdd4e59240 100644 --- a/packages/host/plugin-manager/src/index.ts +++ b/packages/host/plugin-manager/src/index.ts @@ -24,6 +24,7 @@ import type { Entry } from '@deepseek-ai/cordis-plugin-loader' import z from '@deepseek-ai/schemastery' import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' import { + awaitChildClose, bundleGroupId, claimLayerIds, disableBundle, @@ -885,21 +886,10 @@ export class PluginManager extends TypertRemoteService { child.stderr?.setEncoding('utf8') child.stdout?.on('data', (text: string) => { record('stdout', text) }) child.stderr?.on('data', (text: string) => { record('stderr', text) }) - const exitCode = await new Promise((resolve, reject) => { - let settled = false - const settle = (outcome: () => void): void => { - if (settled) return - settled = true - clearTimeout(timer) - outcome() - } - const timer = setTimeout(() => { - child.kill('SIGKILL') - settle(() => { reject(new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`)) }) - }, this.config.installTimeoutMs) - child.on('error', (error) => { settle(() => { reject(error) }) }) - child.on('close', (code) => { settle(() => { resolve(code) }) }) - }).catch((error: unknown) => { + const exitCode = await awaitChildClose( + child, this.config.installTimeoutMs, + () => new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`), + ).catch((error: unknown) => { const message = messageOf(error) record('stderr', `${message}\n`) this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stderr', text: '', exitCode: null }) From 26c4a324a1127c14a0c1d647856c25b55998da0d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 17:16:00 +0800 Subject: [PATCH 3/4] test(plugins): cover the removal of a bundle the profile cannot resolve --- .../tests/plugin-manager.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/host/plugin-manager/tests/plugin-manager.spec.ts b/packages/host/plugin-manager/tests/plugin-manager.spec.ts index a94abcd118..66a1cf12ca 100644 --- a/packages/host/plugin-manager/tests/plugin-manager.spec.ts +++ b/packages/host/plugin-manager/tests/plugin-manager.spec.ts @@ -475,6 +475,24 @@ describe('PluginManager', () => { expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-two') }) + it('removes an installed bundle the profile cannot resolve and says why', async () => { + const staged = await stageHome() + // The probe reads the manifest without judging the stage; resolving the + // layer is what refuses it, and that refusal is the removal's reason. + stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW, stage: 'weird' }) + const calls: string[][] = [] + const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) }) + + const result = await manager.install('ext-odd') + + expect(result).toMatchObject({ + installed: [], installedOnly: [], + removed: [{ name: 'ext-odd', reason: expect.stringContaining('declares stage "weird"') as string }], + }) + expect(calls).toEqual([['pnpm', 'add', 'ext-odd'], ['pnpm', 'remove', 'ext-odd']]) + expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-odd') + }) + it('keeps a package whose probe refused it, for the view to explain', async () => { const staged = await stageHome() stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' }) From cc04382160de36657b76cddcbaf402fbe77ab8da Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 5 Sep 2026 17:18:40 +0800 Subject: [PATCH 4/4] test(plugins): a staged package may declare a stage the profile refuses --- packages/host/plugin-manager/tests/plugin-manager.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/host/plugin-manager/tests/plugin-manager.spec.ts b/packages/host/plugin-manager/tests/plugin-manager.spec.ts index 66a1cf12ca..b9ad419033 100644 --- a/packages/host/plugin-manager/tests/plugin-manager.spec.ts +++ b/packages/host/plugin-manager/tests/plugin-manager.spec.ts @@ -85,7 +85,8 @@ interface StagedPackage { /** `index.js` text, exported as the package main. */ main?: string version?: string - stage?: 'boot' | 'runtime' + /** Any string: a staged manifest may declare a stage the profile refuses. */ + stage?: string plugins?: { name: string; title?: string; config?: unknown }[] files?: Record }