Просмотр исходного кода

Revert #932 transactional Cordis reload changes

Apply real git revert --no-commit operations in reverse order:

acb0797a1421eab128a4bd03355e930c1a221fb0
 af97714f2413a3e55f0f15ab1b283beb133b3f2d
2018b0b7e64b444bff82b62c9466f13adc4603bb
43170c027cfbf9155dad7a43c01ca39364d4aef2
a621e927d6b3763798c3929e28912d86c9576f2d

Resolve the app-boot package move, preserve deleted files and frozen archived
notes, retain the modern lockfile graph, and preserve independent later
lifecycle and lazy-config changes. Do not revert 6ac7d72d: that merge also
contains eight unrelated commits from the #936 dependency chain.

This is the mechanical intermediate result. Its focused check exposed
consumer and packaging incompatibilities. The following compatibility
commit supplies those adaptations and reconciles the vendor ledger.
turtle1999 1 неделя назад
Родитель
Сommit
e07f41d5fd

+ 0 - 1
package.json

@@ -133,7 +133,6 @@
     "verify-client-packages": "tsx scripts/verify-client-packages.ts",
     "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
     "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts",
-    "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
     "rescope-vendor": "tsx scripts/rescope-vendor.ts",
     "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check",

+ 15 - 42
packages/boot/app-boot/src/index.ts

@@ -780,9 +780,6 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * complete plugin set.
  * @returns the root context once every entry has started, 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
- * preparation failed` when `prepare` threw before any config-tree entry
- * mounted, `plugin tree failed to load` afterwards.
  */
 export async function boot(
   binName: string,
@@ -792,45 +789,21 @@ export async function boot(
   bareModuleBaseUrl?: string,
 ): Promise<Context> {
   const ctx = new Context()
-  // Two failure labels: `prepare` runs before any config-tree entry mounts,
-  // so its failure is host setup, not the plugin tree.
-  let stage = 'host preparation failed'
-  try {
-    ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
-    ctx.provide('dshHomePath', dshHomePath)
-    await ctx.plugin(Loader)
-    await prepare?.(ctx)
-    stage = 'plugin tree failed to load'
-    await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
-    // A surface can finish and dispose the whole tree while startup is still
-    // in flight, before the last entry settles. The Loader service goes with
-    // it, and the activation audit describes a live tree — reading `ctx.loader`
-    // past this point would throw a TypeError over an app that exited exactly
-    // as asked. Transactional group updates settle
-    // lifecycle inside the mount, so the teardown can land before it returns;
-    // re-check after every await.
-    await ctx.get('loader')?.await()
-    if (ctx.get('loader') === undefined) return ctx
-    await assertEntriesActivated(ctx, binName)
-    return ctx
-  } catch (cause) {
-    // Root-fiber disposal contains cleanup failures per observer (Cordis
-    // 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()
-    const detail = cause instanceof Error ? cause.message : String(cause)
-    // The transactional Loader wraps a failing entry apply in one message per
-    // tree layer; every layer's message is folded into `detail` above, and the
-    // deepest cause is the plugin's own thrown error, whose stack names the
-    // real failure site — append it so the startup diagnostic preserves the
-    // original activation error instead of only the wrap chain.
-    let deepest: unknown = cause
-    while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
-    const stack = deepest instanceof AggregateError
-      ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
-      : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
-    throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
-  }
+  ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
+  ctx.provide('dshHomePath', dshHomePath)
+  await ctx.plugin(Loader)
+  await prepare?.(ctx)
+  await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
+  await ctx.loader.await()
+  // A surface can finish and dispose the whole tree while that await is still
+  // pending: the TUI renders as soon as its own fiber starts, so an `/exit`
+  // typed before the last entry settles tears the context down under us. The
+  // Loader service goes with it, and the activation audit describes a live
+  // tree — reading `ctx.loader` here would throw a TypeError over an app that
+  // exited exactly as asked.
+  if (ctx.get('loader') === undefined) return ctx
+  await assertEntriesActivated(ctx, binName)
+  return ctx
 }
 
 /** Prompt-section name for the harness-source location line an app bin adds after boot. */

+ 2 - 18
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -729,22 +729,6 @@ describe('boot', () => {
     }
   })
 
-  it('disposes partial host setup and labels non-Error preparation failures', async () => {
-    const dir = tmp()
-    const failure = 42
-    let disposed = false
-    const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
-      ctx.effect(() => () => { disposed = true })
-      throw failure
-    })
-
-    await expect(task).rejects.toMatchObject({
-      message: `${NAME}: host preparation failed: ${failure}`,
-      cause: failure,
-    })
-    expect(disposed).toBe(true)
-  })
-
   it('exposes dshHomePath to Loader config expressions', async () => {
     const dir = tmp()
     const dshHome = join(dir, 'home')
@@ -851,9 +835,9 @@ describe('boot', () => {
     const deepest = new Error('stackless deep failure')
     delete (deepest as { stack?: string }).stack
     await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
-      throw new Error('wrapped setup failure', { cause: deepest })
+      throw new Error('host preparation failed', { cause: deepest })
     })).rejects.toThrow(
-      `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
+      `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`,
     )
   })
 

+ 15 - 227
packages/boot/app-boot/tests/config-reload.spec.ts

@@ -1,7 +1,12 @@
 /**
- * Transactional config replacement through the booted Include and Loader tree.
- * HMR contains rejected refreshes; direct callers receive the error after the
- * previous generation has been retained or restored.
+ * Config hot-reload resilience of the booted include tree. `dsh-app-boot`
+ * installs a fail-loud unhandled-rejection handler, so a `refresh()` that
+ * rethrows a config-file parse error would kill a live app on one bad
+ * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
+ * callback nobody else catches). These tests pin the vendored
+ * `@cordisjs/plugin-include` contract that boot relies on: an invalid file
+ * keeps the last good tree, and a valid re-read re-applies overlay patches
+ * exactly like the initial load.
  */
 
 import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -27,11 +32,10 @@ interface TreeFixture {
   include: Include
 }
 
-async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
+async function bootTree(configBody: string): Promise<TreeFixture> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
   tempRoots.push(dir)
   writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
-  for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
   writeFileSync(join(dir, 'cordis.yml'), configBody)
   const ctx = await boot(NAME, join(dir, 'cordis.yml'))
   const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
@@ -43,41 +47,20 @@ function entryConfig(ctx: Context, id: string): unknown {
   return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
 }
 
-function entryById(ctx: Context, id: string) {
-  const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
-  if (!entry) throw new Error(`missing loader entry ${id}`)
-  return entry
-}
-
-function plugin(name: string, body = ''): string {
-  return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
-}
-
-async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
-  try {
-    await task
-  } catch (error) {
-    expect(error).toBeInstanceOf(Error)
-    expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
-    return
-  }
-  throw new Error(`expected loader update to fail during ${stage}`)
-}
-
 describe('include refresh with an invalid file', () => {
-  it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
+  it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
     const { ctx, dir, include } = await bootTree('- id: noop\n  name: ./noop.mjs\n  config:\n    value: 1\n')
     try {
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
 
       writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
-      await expect(include.refresh()).rejects.toThrow('failed to parse config file')
+      await expect(include.refresh()).resolves.toBeUndefined()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
 
       // An empty file parses to `undefined` without a YAML error; it must be
       // treated exactly like a parse failure, not crash the entry walk.
       writeFileSync(join(dir, 'cordis.yml'), '')
-      await expect(include.refresh()).rejects.toThrow('failed to validate config file')
+      await expect(include.refresh()).resolves.toBeUndefined()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
 
       writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n  config:\n    value: 2\n')
@@ -90,201 +73,6 @@ describe('include refresh with an invalid file', () => {
   })
 })
 
-describe('loader entry replacement', () => {
-  it('imports a changed name before replacing the running plugin', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./old.mjs\n', {
-      'old.mjs': plugin('oldPlugin'),
-      'new.mjs': plugin('newPlugin'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      await entry.update({ name: './new.mjs' })
-      expect(entry.options.name).toBe('./new.mjs')
-      expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
-      expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
-      expect(entry.options.disabled).toBeUndefined()
-      await entry.fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('retains the running plugin when the replacement cannot be imported', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./old.mjs\n', {
-      'old.mjs': plugin('oldPlugin'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const fiber = entry.fiber
-      await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
-      expect(entry.options.name).toBe('./old.mjs')
-      expect(entry.fiber === fiber).toBe(true)
-      await fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('restores the previous plugin after replacement application fails', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./old.mjs\n', {
-      'old.mjs': plugin('oldPlugin'),
-      'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const previous = entry.fiber
-      await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
-      expect(entry.options.name).toBe('./old.mjs')
-      expect(entry.fiber === previous).toBe(false)
-      expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
-      expect(entry.options.disabled).toBeUndefined()
-      await entry.fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('restores the previous config when an in-place restart fails', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./configurable.mjs\n  config:\n    fail: false\n', {
-      'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const fiber = entry.fiber
-      await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
-      expect(entry.options.config).toEqual({ fail: false })
-      expect(entry.fiber === fiber).toBe(true)
-      await fiber?.await()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('does not persist a failed direct fiber update', async () => {
-    const { ctx } = await bootTree('- id: target\n  name: ./configurable.mjs\n  config:\n    fail: false\n', {
-      'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
-    })
-    try {
-      const entry = entryById(ctx, 'target')
-      const fiber = entry.fiber
-      if (!fiber) throw new Error('target entry has no fiber')
-      await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
-      expect(entry.options.config).toEqual({ fail: false })
-      expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-})
-
-describe('loader tree replacement', () => {
-  it('rolls back earlier updates and additions when a later entry fails', async () => {
-    const { ctx, dir, include } = await bootTree([
-      '- id: existing',
-      '  name: ./configurable.mjs',
-      '  config:',
-      '    value: old',
-      '',
-    ].join('\n'), {
-      'configurable.mjs': plugin('configurablePlugin'),
-      'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
-    })
-    try {
-      writeFileSync(join(dir, 'cordis.yml'), [
-        '- id: existing',
-        '  name: ./configurable.mjs',
-        '  config:',
-        '    value: candidate',
-        '- id: added',
-        '  name: ./noop.mjs',
-        '- id: bad',
-        '  name: ./bad.mjs',
-        '',
-      ].join('\n'))
-      await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
-      expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
-      expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
-      expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
-
-      writeFileSync(join(dir, 'cordis.yml'), [
-        '- id: existing',
-        '  name: ./configurable.mjs',
-        '  config:',
-        '    value: committed',
-        '- id: added',
-        '  name: ./noop.mjs',
-        '',
-      ].join('\n'))
-      await include.refresh()
-      expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
-      expect(entryById(ctx, 'added').fiber).toBeDefined()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
-    // No manual builtin registration: `boot()` supplies `cordis:group` beside
-    // `cordis:include`, which is what lets a composition give one `isolate`
-    // realm to a provider and its consumers together.
-    const { ctx, dir, include } = await bootTree('- id: noop\n  name: ./noop.mjs\n')
-    try {
-      const config = (disabled: boolean) => [
-        '- id: parent',
-        '  name: cordis:group',
-        '  group: true',
-        `  disabled: ${disabled}`,
-        '  config:',
-        '    - id: child',
-        '      name: ./noop.mjs',
-        '',
-      ].join('\n')
-
-      writeFileSync(join(dir, 'cordis.yml'), config(false))
-      await include.refresh()
-      expect(entryById(ctx, 'child').fiber).toBeDefined()
-
-      writeFileSync(join(dir, 'cordis.yml'), config(true))
-      await include.refresh()
-      expect(entryById(ctx, 'child').fiber).toBeUndefined()
-
-      writeFileSync(join(dir, 'cordis.yml'), config(false))
-      await include.refresh()
-      expect(entryById(ctx, 'child').fiber).toBeDefined()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('restores a programmatic entry move when its update fails', async () => {
-    const { ctx } = await bootTree('- id: noop\n  name: ./noop.mjs\n', {
-      'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
-    })
-    try {
-      const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
-      const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
-      const target = entryById(ctx, targetId)
-      const source = target.parent
-      const sourceIndex = source.data.indexOf(target.options)
-      const destination = entryById(ctx, groupId).subgroup
-      if (!destination) throw new Error('created loader group has no subgroup')
-
-      await expectUpdateFailure(
-        ctx.loader.update(targetId, { config: { fail: true } }, groupId),
-        'apply',
-      )
-
-      expect(target.parent).toBe(source)
-      expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
-      expect(source.data.indexOf(target.options)).toBe(sourceIndex)
-      expect(destination.data).not.toContain(target.options)
-      expect(target.options.config).toEqual({ fail: false })
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-})
-
 describe('include refresh with overlay patches', () => {
   it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
@@ -335,9 +123,9 @@ describe('include refresh with overlay patches', () => {
       await ctx.loader.await()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
 
-      // Omitting the patch list must remove the overlay rather than reuse the
-      // Include's previous config through a default parameter.
-      await entry.update({ config: { path: './base.yml' } })
+      // Removing every patch must revert to the file's own values: patching
+      // may not bake earlier patch results into the cached parse.
+      await entry.update({ config: { path: './base.yml', patches: [] } })
       await ctx.loader.await()
       expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
     } finally {

+ 0 - 213
packages/boot/app-boot/tests/hmr-config.spec.ts

@@ -1,213 +0,0 @@
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
-import { realpath } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { Context } from '@deepseek-ai/cordis'
-import Hmr from '@deepseek-ai/cordis-plugin-hmr'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
-import Timer from '@deepseek-ai/cordis-plugin-timer'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-/** Every per-test tree root, removed once the booted watcher has been disposed. */
-const hmrRoots: string[] = []
-
-async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
-  const ctx = new Context()
-  ctx.baseUrl = pathToFileURL(dir).href + '/'
-  await ctx.plugin(Loader)
-  await ctx.plugin(Timer)
-  await ctx.plugin(Hmr, {
-    root,
-    ignored: [],
-    debounce: 0,
-    ...usePolling === undefined ? {} : { usePolling },
-  })
-  return ctx
-}
-
-async function eventually(test: () => boolean, message: string): Promise<void> {
-  const deadline = Date.now() + 10_000
-  while (!test()) {
-    if (Date.now() >= deadline) throw new Error(message)
-    await new Promise(resolve => setTimeout(resolve, 10))
-  }
-}
-
-describe('HMR exact config paths', () => {
-  afterEach(() => {
-    for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
-  })
-
-  it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
-    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
-    const alias = `${target}-alias`
-    const aliasFilename = join(alias, 'module.ts')
-    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
-    writeFileSync(aliasFilename, 'export const generation = 0\n')
-    // This acceptance owns alias-to-cache identity. Other cases below exercise
-    // native events; polling keeps Windows fs.watch queue pressure out of it.
-    const ctx = await bootHmr(alias, ['.'], true)
-    const filename = join(await realpath(target), 'module.ts')
-    const expected = pathToFileURL(filename).href
-    const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
-    const observed: string[] = []
-    ctx.on('hmr/change', (url) => { observed.push(url) })
-    try {
-      const deadline = Date.now() + 20_000
-      for (let generation = 1; !observed.includes(expected); generation += 1) {
-        if (Date.now() >= deadline) {
-          throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
-        }
-        // The watch base, not the writer spelling, is the alias under test.
-        // Grow the file on every write: polling must not depend on timestamp
-        // precision when several generations land inside one filesystem tick.
-        writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
-        // Leave Chokidar's atomic-write window idle so one coalesced change can publish.
-        await new Promise(resolve => setTimeout(resolve, 250))
-      }
-      expect(cacheHas).toHaveBeenCalledWith(expected)
-    } finally {
-      await ctx.fiber.dispose()
-      unlinkSync(alias)
-      rmSync(target, { recursive: true, force: true })
-    }
-  })
-
-  it('collapses filesystem aliases before registering an exact watch', async () => {
-    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
-    const alias = `${target}-alias`
-    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
-    const ctx = await bootHmr(alias)
-    try {
-      await ctx.hmr.registerConfig('plugins.yml', () => {})
-      await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {}))
-        .rejects.toThrow('config path already registered')
-    } finally {
-      await ctx.fiber.dispose()
-      unlinkSync(alias)
-      rmSync(target, { recursive: true, force: true })
-    }
-  })
-
-  it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(dir)
-    const filename = join(dir, 'plugins.yml')
-    const ctx = await bootHmr(dir)
-    const observed: string[] = []
-    try {
-      await ctx.hmr.registerConfig(filename, () => {
-        try {
-          observed.push(readFileSync(filename, 'utf8'))
-        } catch (error) {
-          if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-          observed.push('missing')
-        }
-      })
-
-      writeFileSync(filename, 'one', { flag: 'wx' })
-      await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
-      writeFileSync(filename, 'two')
-      await eventually(() => observed.includes('two'), 'HMR did not observe config change')
-      unlinkSync(filename)
-      await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
-    const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(root)
-    const dir = join(root, 'later')
-    const filename = join(dir, 'plugins.yml')
-    const ctx = await bootHmr(root)
-    const observed: string[] = []
-    try {
-      await ctx.hmr.registerConfig(filename, () => {
-        observed.push(readFileSync(filename, 'utf8'))
-      })
-      mkdirSync(dir)
-      writeFileSync(filename, 'created')
-      await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(dir)
-    const filename = join(dir, 'plugins.yml')
-    writeFileSync(filename, 'one')
-    const ctx = await bootHmr(dir)
-    const started = Promise.withResolvers<undefined>()
-    const release = Promise.withResolvers<undefined>()
-    const observed: string[] = []
-    let active = 0
-    let maxActive = 0
-    try {
-      const dispose = await ctx.hmr.registerConfig(filename, async () => {
-        active += 1
-        maxActive = Math.max(maxActive, active)
-        observed.push(readFileSync(filename, 'utf8'))
-        if (observed.length === 1) {
-          started.resolve(undefined)
-          await release.promise
-        }
-        active -= 1
-      })
-      await started.promise
-      writeFileSync(filename, 'two')
-      // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
-      // that window so this edit is queued before registration disposal.
-      await new Promise(resolve => setTimeout(resolve, 250))
-
-      let disposed = false
-      const disposal = dispose().then(() => { disposed = true })
-      await Promise.resolve()
-      expect(disposed).toBe(false)
-      release.resolve(undefined)
-      await disposal
-      expect(maxActive).toBe(1)
-      expect(observed).toEqual(['one', 'two'])
-    } finally {
-      release.resolve(undefined)
-      await ctx.fiber.dispose()
-    }
-  })
-
-  it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
-    hmrRoots.push(dir)
-    const filename = join(dir, 'plugins.yml')
-    const ctx = await bootHmr(dir)
-    const failure = Promise.withResolvers<{ filename: string; error: Error }>()
-    let failureCount = 0
-    try {
-      ctx.on('hmr/config-update-failed', () => {
-        throw new Error('observer failed')
-      })
-      ctx.on('hmr/config-update-failed', (failedFilename, error) => {
-        failureCount += 1
-        failure.resolve({ filename: failedFilename, error })
-      })
-      await ctx.hmr.registerConfig(filename, () => { throw 42 })
-      writeFileSync(filename, 'invalid')
-
-      const observed = await failure.promise
-      expect(observed.filename).toBe(filename)
-      expect(observed.error).toBeInstanceOf(Error)
-      expect(observed.error.message).toBe('42')
-
-      // Let Chokidar's atomic-write window close before requiring a distinct
-      // second notification from the same path.
-      await new Promise(resolve => setTimeout(resolve, 250))
-      writeFileSync(filename, 'invalid again')
-      await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-})

+ 5 - 4
packages/host/directory-picker-auto/src/index.ts

@@ -77,10 +77,11 @@ export async function apply(ctx: Context): Promise<void> {
       for (const id of [...ids].reverse()) {
         // Tree teardown (group.stop) can have removed the entry already;
         // nothing is left to unmount or await then.
-        if (ctx.loader.store[id] === undefined) continue
-        // remove() disposes the entry transactionally, so the chooser's unload
-        // signals completion only after that face quiesced.
-        await ctx.loader.remove(id)
+        const entry = ctx.loader.store[id]
+        if (entry === undefined) continue
+        const fiber = entry.fiber
+        ctx.loader.remove(id)
+        await fiber?.dispose()
       }
     }
     try {

+ 1 - 1
packages/host/directory-picker-auto/tests/loader-composition.spec.ts

@@ -259,7 +259,7 @@ describe('real Loader composition', () => {
     const { ctx, configPath } = await loadComposition('127.0.0.1')
 
     const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
-    await ctx.loader.remove(backendEntry.id)
+    ctx.loader.remove(backendEntry.id)
     const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
     renameControl.remainingFailures = 1
     await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()

+ 16 - 8
packages/host/webserver/tests/webserver.spec.ts

@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, FiberState } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
 import HttpServer, { renderIndexInjections } from '../src/index.ts'
@@ -361,17 +361,25 @@ describe('real Loader composition', () => {
     const firstRoot = root
     root = undefined // keep the first composition's files until the end
 
+    // loader.await() never rejects (allSettled); the bind failure surfaces as
+    // a FAILED fiber whose error escapes as a late rejection — the shape the
+    // boot's installFailLoud is contracted to catch. Capture it here the same
+    // way, and assert it really is the bind error.
+    const rejections: unknown[] = []
+    const onUnhandled = (err: unknown): void => { rejections.push(err) }
+    process.on('unhandledRejection', onUnhandled)
     let second: Context | undefined
     try {
-      let failure: unknown
-      try {
-        await loadComposition(takenPort)
-      } catch (error) {
-        failure = error
+      second = await loadComposition(takenPort)
+      const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
+      expect(entry?.fiber?.state).toBe(FiberState.FAILED)
+      // The rejection escapes a tick after loader.await() settles; bounded poll.
+      for (let i = 0; i < 100 && rejections.length === 0; i++) {
+        await new Promise(resolve => setTimeout(resolve, 10))
       }
-      second = context
-      expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
+      expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
     } finally {
+      process.off('unhandledRejection', onUnhandled)
       await second?.fiber.dispose()
       context = first
       if (root !== undefined) await rm(root, { recursive: true, force: true })

+ 2 - 2
packages/typert/loader/tests/loader.spec.ts

@@ -212,12 +212,12 @@ describe('typert loader', () => {
     await new Promise(resolve => setTimeout(resolve, 20))
     expect(ctx.typert.list()).toHaveLength(1)
 
-    await ctx.loader.remove(id)
+    ctx.loader.remove(id)
     await ctx.loader.await()
     // The unmount reconciliation rides a queued microtask flush.
     await new Promise(resolve => setTimeout(resolve, 20))
     expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined()
-    await ctx.loader.remove(plainId)
+    ctx.loader.remove(plainId)
     await ctx.loader.await()
     await new Promise(resolve => setTimeout(resolve, 20))
 

+ 0 - 4
pnpm-workspace.yaml

@@ -14,10 +14,6 @@ packages:
   # closure is what the exe bundles and what the Python runtime distributes.
   - python/sdk-runtime
 
-# Vendored framework packages keep their upstream semver ranges, while local
-# builds must resolve those matching names to this workspace's pinned sources.
-linkWorkspacePackages: true
-
 overrides:
   '@deepseek-ai/cosmokit': 'link:vendor/cosmokit'
   '@deepseek-ai/schemastery': 'link:vendor/schemastery'

+ 0 - 72
scripts/verify-vendored-links.ts

@@ -1,72 +0,0 @@
-/**
- * Verify that pnpm-lock.yaml resolves every vendored package name to its
- * workspace `link:` — never a registry copy. `linkWorkspacePackages: true`
- * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the
- * pinned vendored sources; a registry copy of the same name coexisting with
- * the vendored one silently forks the framework layer (vendor/README.md).
- */
-import { readdir, readFile } from 'node:fs/promises'
-import { join, resolve } from 'node:path'
-import * as yaml from 'js-yaml'
-
-const root = resolve(import.meta.dirname, '..')
-
-async function vendoredNames(): Promise<Set<string>> {
-  const names = new Set<string>()
-  for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
-    if (!entry.isDirectory()) continue
-    let manifest: { name?: string }
-    try {
-      manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string }
-    } catch {
-      continue // not a package directory (e.g. vendor/README.md siblings)
-    }
-    if (manifest.name !== undefined) names.add(manifest.name)
-  }
-  return names
-}
-
-interface Lockfile {
-  importers?: Record<string, Record<string, unknown>>
-  packages?: Record<string, unknown>
-  snapshots?: Record<string, unknown>
-}
-
-const names = await vendoredNames()
-if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/')
-const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile
-
-const violations: string[] = []
-
-// Importer resolutions: every dependency entry naming a vendored package must
-// resolve to a link:, or the build silently uses a registry copy.
-for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) {
-  for (const [section, dependencies] of Object.entries(sections)) {
-    if (typeof dependencies !== 'object' || dependencies === null) continue
-    for (const [dependency, entry] of Object.entries(dependencies as Record<string, { version?: string }>)) {
-      if (!names.has(dependency)) continue
-      const version = entry.version ?? ''
-      if (!version.startsWith('link:')) {
-        violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`)
-      }
-    }
-  }
-}
-
-// Package/snapshot keys: a registry copy materializes as a `<name>@<version>`
-// key; vendored names must never appear there at all.
-for (const section of ['packages', 'snapshots'] as const) {
-  for (const key of Object.keys(lockfile[section] ?? {})) {
-    const atIndex = key.lastIndexOf('@')
-    if (atIndex <= 0) continue
-    const packageName = key.slice(0, atIndex)
-    if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`)
-  }
-}
-
-if (violations.length > 0) {
-  console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`)
-  for (const violation of violations) console.error(`  - ${violation}`)
-  process.exit(1)
-}
-console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`)

+ 3 - 3
vendor/README.md

@@ -2,7 +2,7 @@
 
 This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned).
 
-All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory.
+All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory.
 
 This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md).
 
@@ -35,9 +35,9 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
-6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation.
+6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
 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. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
+8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially and undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`.
 10. **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.
 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` 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 an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays 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/boot/app-boot/tests/config-reload.spec.ts`.

+ 1 - 1
vendor/cordis/src/events.ts

@@ -340,7 +340,7 @@ export interface Events {
   /** Interception hook for a service binding (no core producer). */
   'internal/service'(this: Context, name: string, value: any): void
   /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
-  'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise<void>): void | Promise<void>
+  'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
   /** Waterfall: a service is being read through the context proxy. */
   'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
   /** Waterfall: a service is being written through the context proxy. */

+ 3 - 3
vendor/cordis/src/fiber.ts

@@ -730,8 +730,8 @@ export class Fiber {
    *
    * @param config — the new raw config; validated before anything restarts.
    * @param noSave — hint for persistence hooks not to write the change back.
-   * @returns the update waterfall result; the default restart returns a promise.
-   * @throws when validation, an update listener, or the restarted plugin fails.
+   * @returns nothing; the restart runs behind the `internal/update` waterfall.
+   * @throws {ValidationError} when the new config fails validation.
    */
   update(config: any, noSave = false) {
     this.assertActive()
@@ -745,7 +745,7 @@ export class Fiber {
       return
     }
     config = this._resolveConfig(config)
-    return this.context.waterfall(this, 'internal/update', config, noSave, () => {
+    this.context.waterfall(this, 'internal/update', config, noSave, () => {
       this.config = config
       this._error = undefined
       return this.restart()

+ 14 - 162
vendor/hmr/src/index.ts

@@ -3,8 +3,8 @@ import type { Dict } from '@deepseek-ai/cosmokit'
 import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
 import type { Include } from '@deepseek-ai/cordis-plugin-include'
 import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
-import { dirname, relative, resolve } from 'node:path'
-import { realpath, stat } from 'node:fs/promises'
+import { relative, resolve } from 'node:path'
+import { realpath } from 'node:fs/promises'
 import { handleError } from './error.ts'
 import type {} from '@deepseek-ai/cordis-plugin-timer'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -20,13 +20,7 @@ declare module '@deepseek-ai/cordis' {
   interface Events {
     'hmr/change'(url: string): void
     'hmr/reload'(reloads: Map<Plugin, Reload>): void
-    /**
-     * A watched config-file refresh failed.
-     * @param filename - Absolute path observed by HMR.
-     * @param error - Normalized refresh failure.
-     * @mode parallel
-     */
-    'hmr/config-update-failed'(filename: string, error: Error): Promise<void> | void
+
   }
 }
 
@@ -52,37 +46,6 @@ interface Reload {
   runtime?: Plugin.Runtime
 }
 
-interface ConfigRefresh {
-  dirty: boolean
-  running?: Promise<void>
-}
-
-interface ConfigRegistration {
-  watcher: FSWatcher
-}
-
-async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
-  let root = dirname(filename)
-  let depth = 0
-  while (true) {
-    try {
-      if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
-      const canonicalRoot = await realpath(root)
-      return {
-        filename: resolve(canonicalRoot, relative(root, filename)),
-        root: canonicalRoot,
-        depth,
-      }
-    } catch (error) {
-      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-      const parent = dirname(root)
-      if (parent === root) throw error
-      root = parent
-      depth += 1
-    }
-  }
-}
-
 class Hmr extends Service {
   static inject = ['loader', 'timer']
 
@@ -90,9 +53,6 @@ class Hmr extends Service {
 
   private internal: ModuleLoader
   private watcher!: FSWatcher
-  private readonly configs = new Map<string, ConfigRegistration>()
-  private readonly configRefreshes = new WeakMap<object, ConfigRefresh>()
-  private readonly refreshTasks = new Set<Promise<void>>()
 
   /**
    * Changes from externals will always trigger a full reload.
@@ -124,68 +84,6 @@ class Hmr extends Service {
     this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
   }
 
-  /**
-   * Watch one exact config path outside the configured module roots.
-   * @param filename - Config path, resolved against the HMR base directory.
-   * @param refresh - Refresh callback run serially on add, change, or unlink.
-   * @returns an asynchronous disposer once the exact watch is ready.
-   * @throws when HMR is inactive, the path is already registered, or watcher startup fails.
-   */
-  async registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>> {
-    if (!this.watcher) throw new Error('HMR is not active')
-    filename = resolve(this.baseDir, filename)
-    const target = await findWatchRoot(filename)
-    const watchFilename = target.filename
-    if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`)
-
-    const { root, depth } = target
-    const watcher = watch(root, {
-      ...this.config,
-      cwd: undefined,
-      depth,
-      ignored: undefined,
-      ignoreInitial: false,
-    })
-    const registration = { watcher }
-    this.configs.set(watchFilename, registration)
-    const onChange = (path: string) => {
-      const observed = resolve(path)
-      if (observed !== filename && observed !== watchFilename) return
-      this.refreshConfig(registration, filename, refresh)
-    }
-    watcher.on('add', onChange)
-    watcher.on('change', onChange)
-    watcher.on('unlink', onChange)
-
-    const ready = Promise.withResolvers<void>()
-    let readyState: 'pending' | 'resolved' | 'rejected' = 'pending'
-    watcher.once('ready', () => {
-      readyState = 'resolved'
-      ready.resolve()
-    })
-    watcher.on('error', (error) => {
-      if (readyState === 'pending') {
-        readyState = 'rejected'
-        ready.reject(error)
-      } else {
-        this.ctx.logger.warn(error)
-      }
-    })
-
-    try {
-      await ready.promise
-      return this.ctx.effect(() => async () => {
-        if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename)
-        await watcher.close()
-        await this.configRefreshes.get(registration)?.running
-      }, 'hmr.registerConfig()')
-    } catch (error) {
-      this.configs.delete(watchFilename)
-      await watcher.close()
-      throw error
-    }
-  }
-
   /**
    * Resolve a module specifier to a URL, compatible with Node 22-24.
    */
@@ -197,12 +95,7 @@ class Hmr extends Service {
   }
 
   async* [Service.init]() {
-    yield async () => {
-      await this.watcher?.close()
-      await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close()))
-      this.configs.clear()
-      await Promise.allSettled([...this.refreshTasks])
-    }
+    yield () => this.watcher?.close()
 
     const { loader } = this.ctx
     const { root, ignored } = this.config
@@ -229,31 +122,15 @@ class Hmr extends Service {
       ...this.config,
       cwd: watchBaseDir,
       ignored: path => match(relative(watchBaseDir, path)),
-      // The initial scan re-announces files the boot just consumed: an `add`
-      // for a config file refreshes an include whose initial apply may still
-      // be in flight, and a failing apply then rolls this plugin back while
-      // the scan-triggered refresh waits on that apply — a teardown deadlock
-      // that strands boot without a diagnostic. Only events after the scan
-      // matter here; `registerConfig` keeps its own initial scan because a
-      // user patch layer present at registration must apply once.
       ignoreInitial: true,
     })
 
     const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
 
-    const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => {
-      this.ctx.logger.debug('%s detected at %C', kind, path)
+    this.watcher.on('change', async (path) => {
+      this.ctx.logger.debug('change detected at %C', path)
       const filename = resolve(watchBaseDir, path)
       const configuredFilename = resolve(this.baseDir, path)
-      // Config reload: the file is a loader config file (e.g. cordis.yml).
-      for (const entry of loader.entries()) {
-        const include = entry.subtree as Include | undefined
-        if (include?.filename !== filename && include?.filename !== configuredFilename) continue
-        this.refreshConfig(include, include.filename, () => include.refresh())
-        return
-      }
-
-      if (kind !== 'change') return
       const url = pathToFileURL(filename).href
 
       // Full reload: the changed file is part of the framework
@@ -267,11 +144,15 @@ class Hmr extends Service {
         return partialReload()
       }
 
+      for (const entry of loader.entries()) {
+        const include = entry.subtree as Include | undefined
+        if (include?.filename !== filename && include?.filename !== configuredFilename) continue
+        await include.refresh()
+        return
+      }
+
       this.ctx.emit('hmr/change', url)
-    }
-    this.watcher.on('add', path => onChange('add', path))
-    this.watcher.on('change', path => onChange('change', path))
-    this.watcher.on('unlink', path => onChange('unlink', path))
+    })
 
     const ready = Promise.withResolvers<void>()
     let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending'
@@ -294,35 +175,6 @@ class Hmr extends Service {
     await ready.promise
   }
 
-  private refreshConfig(key: object, filename: string, refresh: () => Promise<void> | void) {
-    const state = this.configRefreshes.get(key) ?? { dirty: false }
-    this.configRefreshes.set(key, state)
-    state.dirty = true
-    if (state.running) return
-    const task = (async () => {
-      do {
-        state.dirty = false
-        try {
-          await refresh()
-        } catch (reason) {
-          const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
-          this.ctx.logger.warn('config reload at %C failed', filename)
-          this.ctx.logger.warn(error)
-          try {
-            await this.ctx.parallel('hmr/config-update-failed', filename, error)
-          } catch (rejection) {
-            this.ctx.logger.warn(rejection)
-          }
-        }
-      } while (state.dirty)
-    })().finally(() => {
-      state.running = undefined
-      this.refreshTasks.delete(task)
-    })
-    state.running = task
-    this.refreshTasks.add(task)
-  }
-
   // hide stack trace from HMR
   getOuterStack = (): string[] => [
     // '    at HMR.partialReload (<anonymous>)',

+ 50 - 88
vendor/include/src/index.ts

@@ -44,8 +44,7 @@ function retryableWriteError(error: unknown): boolean {
  * Apply patch lists to an entry list — THE patch semantics of this include,
  * shared by mounting (`applyPatches`) and offline config tooling
  * (`dsh --dump-config`) so a dump can never drift from what boots. The input
- * is never mutated and the result is always detached from it (even with no
- * patches): patching or mounting shared entry objects would bake earlier
+ * is never mutated: patching shared entry objects would bake earlier patch
  * values into the cached parse, so repeated application (config hot-reloads)
  * could never revert a removed or changed patch. Inserted entries are indexed
  * as they are added, so a later patch in the same list can target a row an
@@ -60,8 +59,8 @@ export function applyEntryPatches(
   patches: PatchOptions[] | undefined,
   warn: (message: string, ...args: any[]) => void,
 ): EntryOptions[] {
+  if (!patches?.length) return [...data]
   data = structuredClone(data)
-  if (!patches?.length) return data
 
   const entryMap = new Map<string, EntryOptions>()
   const buildMap = (entries: EntryOptions[]) => {
@@ -127,20 +126,6 @@ export function applyEntryPatches(
   return data
 }
 
-type ConfigUpdateStage = 'read' | 'parse' | 'validate'
-
-interface ReadCandidate {
-  content: string
-  data: EntryOptions[]
-}
-
-class ConfigFileError extends Error {
-  constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
-    super(`failed to ${stage} config file ${path}`, { cause })
-    this.name = 'ConfigFileError'
-  }
-}
-
 /** Runtime patch applied to entries loaded from an included config file. */
 export interface PatchOptions {
   id?: string
@@ -189,7 +174,6 @@ export class Include extends EntryTree {
   private writeTask?: NodeJS.Timeout | undefined
   private pendingWrite?: EntryOptions[]
   private writeQueue: Promise<void> = Promise.resolve()
-  private applyQueue: Promise<unknown> = Promise.resolve()
 
   constructor(ctx: Context, public config: Include.Config) {
     super(ctx)
@@ -203,31 +187,20 @@ export class Include extends EntryTree {
     this.readonly = !this.type
     this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
 
-    ctx.on('internal/update', async (config, _, next) => {
+    ctx.on('internal/update', (config, _, next) => {
       if (config.path !== this.config.path) return next()
-      await this.enqueue(async () => {
-        const data = this.applyPatches(this.data!, config.patches)
-        await this.root.update(data)
-        this.config = config
+      // Veto the fiber restart (children update in place), but persist the new
+      // config ourselves — `Fiber.update` only assigns `this.config` behind
+      // `next()`, and a stale `this.config.patches` would make the next
+      // `refresh()` re-apply the old overlay.
+      this.config = config
+      this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
+        this.ctx.logger.warn('config update at %C failed', this.filename)
+        this.ctx.logger.warn(error)
       })
     })
   }
 
-  /**
-   * Serialize one child-tree mutation behind every earlier one. The group's
-   * transactional `update` is not reentrant: two concurrent applies (the init
-   * apply racing an HMR-triggered refresh from the watcher's initial scan)
-   * interleave create and rollback on the same entries and strand the include
-   * fiber without settling, so every apply path funnels through this queue.
-   * A predecessor's failure is its own caller's outcome and never gates the
-   * next task.
-   */
-  private enqueue<T>(task: () => Promise<T>): Promise<T> {
-    const run = this.applyQueue.then(task, task)
-    this.applyQueue = run.then(() => {}, () => {})
-    return run
-  }
-
   private async checkAccess() {
     if (!this.type) return
     try {
@@ -237,55 +210,56 @@ export class Include extends EntryTree {
     }
   }
 
-  private async read(forced = false): Promise<ReadCandidate | undefined> {
-    let content: string
-    try {
-      content = await readFile(this.filename, 'utf8')
-    } catch (error) {
-      throw new ConfigFileError('read', this.filename, error)
-    }
-    if (!forced && this.content === content) return
+  private async read(forced = false) {
+    const content = await readFile(this.filename, 'utf8')
+    if (!forced && this.content === content) return false
     let data: any
-    try {
-      if (this.type === 'application/yaml') {
-        data = yaml.load(content, { schema })
-      } else if (this.type === 'application/json') {
-        data = JSON.parse(content)
-      } else {
-        const module = await import(/* @vite-ignore */ this.filename)
-        data = module.default || module
-      }
-    } catch (error) {
-      throw new ConfigFileError('parse', this.filename, error)
+    if (this.type === 'application/yaml') {
+      data = yaml.load(content, { schema: entryListSchema })
+    } else if (this.type === 'application/json') {
+      data = JSON.parse(content)
+    } else {
+      const module = await import(/* @vite-ignore */ this.filename)
+      data = module.default || module
     }
+    // An empty or truncated file (common mid-edit: editors and `sed -i` write
+    // through temp states) parses to `undefined`, not an error; reject every
+    // non-array shape here so callers see one "invalid file" signal. Content
+    // and data commit only on success, so an edit that is later reverted to
+    // the exact last good content correctly reads as "unchanged".
     if (!Array.isArray(data)) {
-      throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
+      throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
     }
-    return { content, data }
+    this.content = content
+    this.data = data
+    await this.checkAccess()
+    return true
   }
 
-  private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] {
+  private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
     return applyEntryPatches(data, patches, (message, ...args) => {
       this.ctx.root.logger?.('loader').warn(message, ...args)
     })
   }
 
   async* [Service.init]() {
-    let candidate: ReadCandidate
     try {
-      candidate = (await this.read(true))!
+      await this.read()
     } catch (error) {
-      if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
+      // Only a missing file falls back to `initial` (or the not-found error):
+      // an existing-but-invalid file must fail loud with its real parse error,
+      // never be mislabelled as absent or silently overwritten.
+      if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
       if (this.config.initial) {
-        await this._writeFile(this.config.initial as any)
-        candidate = (await this.read(true))!
+        this.writeFile(this.config.initial as any)
+        await this.read()
       } else {
         throw new Error(`config file not found: ${this.filename}`)
       }
     }
 
     yield () => this.stop()
-    await this.apply(candidate)
+    await this.root.update(this.applyPatches(this.data!))
   }
 
   async stop() {
@@ -294,30 +268,18 @@ export class Include extends EntryTree {
   }
 
   /**
-   * Re-read the file and transactionally refresh child entries when content changed.
-   * @returns a promise resolving after the new tree commits, or immediately when unchanged.
-   * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
+   * Re-read the file and refresh child entries when content changed. An
+   * unreadable or unparsable file logs a warning and keeps the last good
+   * tree: a hot-reload of a live app must never take the process down.
    */
   async refresh() {
-    // Read inside the queue so the changed-content check compares against the
-    // predecessor's committed state, not a mid-apply snapshot.
-    await this.enqueue(async () => {
-      const candidate = await this.read()
-      if (!candidate) return
-      await this._apply(candidate)
-    })
-  }
-
-  private apply(candidate: ReadCandidate) {
-    return this.enqueue(() => this._apply(candidate))
-  }
-
-  private async _apply(candidate: ReadCandidate) {
-    const data = this.applyPatches(candidate.data, this.config.patches)
-    await this.root.update(data)
-    this.content = candidate.content
-    this.data = candidate.data
-    await this.checkAccess()
+    try {
+      if (!await this.read()) return
+      await this.root.update(this.applyPatches(this.data!))
+    } catch (error) {
+      this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
+      this.ctx.logger.warn(error)
+    }
   }
 
   private async _writeFile(config: EntryOptions[]) {

+ 42 - 156
vendor/loader/src/config/entry.ts

@@ -21,11 +21,6 @@ export interface EntryOptions {
   inject?: Inject | null
 }
 
-function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) {
-  const detail = cause instanceof Error ? cause.message : String(cause)
-  return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause })
-}
-
 function takeEntries(object: {}, keys: string[]) {
   const result: [string, any][] = []
   for (const key of keys) {
@@ -43,11 +38,6 @@ function sortKeys<T extends {}>(object: T, prepend = ['id', 'name'], append = ['
   return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2]))
 }
 
-function replaceKeys<T extends {}>(target: T, source: T): T {
-  for (const key of Object.keys(target)) Reflect.deleteProperty(target, key)
-  return Object.assign(target, source)
-}
-
 /** One configured plugin node inside an `EntryTree`. */
 export class Entry {
   static readonly key = Symbol.for('cordis.entry')
@@ -61,7 +51,6 @@ export class Entry {
   public subtree?: EntryTree
 
   _initTask?: Promise<void>
-  _disposing = 0
 
   constructor(public loader: Loader) {
     this.ctx = loader.ctx.extend({ [Entry.key]: this })
@@ -82,18 +71,13 @@ export class Entry {
 
   /** True when this entry or any owning parent entry is disabled. */
   get disabled() {
-    return this._disabled(this.options)
-  }
-
-  private _disabled(options: EntryOptions) {
     // group is always enabled
-    if (options.group) return false
-    if (this.disabledOf(options)) return true
-    let entry = this.parent.ctx.fiber.entry
-    while (entry) {
+    if (this.options.group) return false
+    let entry: Entry | undefined = this
+    do {
       if (this.disabledOf(entry.options)) return true
       entry = entry.parent.ctx.fiber.entry
-    }
+    } while (entry)
     return false
   }
 
@@ -111,12 +95,12 @@ export class Entry {
     return evaluate(this.ctx, expr)
   }
 
-  private async _patchContext(diff: string[]) {
-    await this.context.waterfall('loader/patch-context', this, async () => {
+  private _patchContext(diff: string[]) {
+    this.context.waterfall('loader/patch-context', this, () => {
       Object.setPrototypeOf(this.ctx, this.parent.ctx)
 
       if (this.fiber?.uid && (diff.includes('config') || this.options.group)) {
-        await this.fiber.update(this.options.config, true)
+        this.fiber.update(this.options.config, true)
       }
     })
   }
@@ -127,122 +111,41 @@ export class Entry {
     await this.init()
   }
 
-  async _dispose(fiber = this.fiber) {
-    if (!fiber) return
-    if (this.fiber === fiber) this.fiber = undefined
-    this._disposing += 1
-    try {
-      await fiber.dispose()
-    } finally {
-      this._disposing -= 1
-    }
-  }
-
   /** Merge new options, restart as needed, and persist through the parent tree. */
   async update(options: Partial<EntryOptions>, create = false, force = false) {
-    const previousOptions = this.options
-    const legacy = { ...previousOptions }
-    const candidate = create ? options as EntryOptions : { ...previousOptions }
-    if (!create) {
+    const legacy = { ...this.options }
+
+    // step 1: update options
+    if (create) {
+      this.options = options as EntryOptions
+    } else {
       for (const [key, value] of Object.entries(options)) {
         if (isNullable(value)) {
-          delete candidate[key as keyof EntryOptions]
+          delete this.options[key]
         } else {
-          candidate[key as keyof EntryOptions] = value as never
+          this.options[key] = value
         }
       }
     }
-    sortKeys(candidate)
-
-    const diff = Object
-      .keys({ ...candidate, ...legacy })
-      .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions]))
-    if (!diff.length && !force) return
-
-    const commit = () => {
-      if (create) return
-      this.options = replaceKeys(previousOptions, candidate)
-    }
-
-    const previous = this.fiber
-    if (!previous?.uid) {
-      this.fiber = undefined
-      this.options = candidate
-      try {
-        if (!this._disabled(candidate)) await this.init()
-      } catch (error) {
-        this.options = previousOptions
-        throw error
-      }
-      commit()
-      return
-    }
+    sortKeys(this.options)
 
-    if (this._disabled(candidate)) {
-      this.options = candidate
-      try {
-        await this._dispose(previous)
-      } catch (error) {
-        this.options = previousOptions
-        throw updateError('dispose', candidate, error)
-      }
-      commit()
-      this.context.emit('loader/partial-dispose', this, legacy, true)
+    // step 2: execute
+    if (this.disabled) {
+      this.fiber?.dispose()
       return
     }
 
-    const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group')
-    if (!replace) {
-      this.options = candidate
-      try {
-        await this._patchContext(diff)
-      } catch (error) {
-        this.options = previousOptions
-        try {
-          await this._patchContext(diff)
-        } catch (rollbackError) {
-          throw updateError('rollback', legacy, new AggregateError([error, rollbackError]))
-        }
-        this.context.emit('loader/partial-dispose', this, candidate, true)
-        throw updateError('apply', candidate, error)
-      }
-      commit()
+    // step 3: check if options are changed
+    if (this.fiber?.uid) {
+      const diff = Object
+        .keys({ ...this.options, ...legacy })
+        .filter(key => !deepEqual(this.options[key], legacy[key]))
+      if (!diff.length && !force) return
       this.context.emit('loader/partial-dispose', this, legacy, true)
-      return
-    }
-
-    let plugin: any
-    try {
-      plugin = diff.includes('name')
-        ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack))
-        : previous.runtime!.callback
-    } catch (error) {
-      throw updateError('import', candidate, error)
-    }
-
-    const previousPlugin = previous.runtime!.callback
-    this.options = candidate
-    try {
-      await this._dispose(previous)
-    } catch (error) {
-      this.options = previousOptions
-      throw updateError('dispose', candidate, error)
+      this._patchContext(diff)
+    } else {
+      await this.init()
     }
-
-    try {
-      await this._start(plugin)
-    } catch (error) {
-      this.options = previousOptions
-      try {
-        await this._start(previousPlugin)
-      } catch (rollbackError) {
-        throw updateError('rollback', legacy, new AggregateError([error, rollbackError]))
-      }
-      this.context.emit('loader/partial-dispose', this, candidate, true)
-      throw updateError('apply', candidate, error)
-    }
-    commit()
-    this.context.emit('loader/partial-dispose', this, legacy, true)
   }
 
   getOuterStack = () => {
@@ -261,43 +164,26 @@ export class Entry {
       await (this._initTask ??= this._init())
     } finally {
       this._initTask = undefined
-      if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader'])
-    }
-    await this._await()
-  }
-
-  async _await() {
-    try {
-      await this.fiber?.await()
-    } catch (error) {
-      throw updateError('apply', this.options, error)
     }
+    this.fiber?.await().finally(() => {
+      if (this.loader.getTasks().length) return
+      this.ctx.reflect.notify(['loader'])
+    })
   }
 
   private async _init() {
-    let plugin: any
-    try {
-      plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack))
-    } catch (error) {
-      throw updateError('import', this.options, error)
-    }
-    try {
-      await this._start(plugin)
-    } catch (error) {
-      throw updateError('apply', this.options, error)
-    }
-  }
-
-  private async _start(plugin: any) {
-    let fiber: Fiber | undefined
+    let exports: any
     try {
-      await this._patchContext([])
-      this.loader.showLog(this, 'apply')
-      fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
-      await fiber.await()
+      exports = await this.parent.tree.import(this.options.name, this.getOuterStack)
     } catch (error) {
-      await this._dispose(fiber)
-      throw error
+      this.ctx.logger.error(error)
+      return
+    } finally {
+      this._initTask = undefined
     }
+    const plugin = this.loader.unwrapExports(exports)
+    this._patchContext([])
+    this.loader.showLog(this, 'apply')
+    this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
   }
 }

+ 21 - 60
vendor/loader/src/config/group.ts

@@ -19,23 +19,12 @@ export class EntryGroup {
 
   async create(options: Omit<EntryOptions, 'id'>) {
     const id = this.tree.ensureId(options)
-    const existing = this.tree.store[id]
-    const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader))
-    const previousParent = entry.parent
+    const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader)
     // Entry may be moved from another group,
     // so we need to update the parent reference.
     entry.parent = this
     // Use `create: true` to replace existing entry.options.
-    try {
-      await entry.update(options, true, true)
-    } catch (error) {
-      if (existing) {
-        entry.parent = previousParent
-      } else {
-        delete this.tree.store[id]
-      }
-      throw error
-    }
+    await entry.update(options, true, true)
     return entry.id
   }
 
@@ -45,10 +34,10 @@ export class EntryGroup {
     if (index >= 0) config.splice(index, 1)
   }
 
-  async remove(id: string, isDispose = false) {
+  remove(id: string, isDispose = false) {
     const entry = this.tree.store[id]
     if (!entry) return
-    await entry._dispose()
+    entry.fiber?.dispose()
     if (!isDispose) {
       this.unlink(entry.options)
     }
@@ -58,56 +47,26 @@ export class EntryGroup {
 
   async update(config: EntryOptions[]) {
     const oldConfig = this.data as EntryOptions[]
-    const seen = new Set<string>()
-    for (const options of config) {
-      const id = this.tree.ensureId(options)
-      if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`)
-      seen.add(id)
-    }
+    this.data = config
     const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options]))
-    const newMap = Object.fromEntries(config.map(options => [options.id, options]))
+    const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options]))
 
-    try {
-      const outcomes = await Promise.allSettled(config.map(options => this.create(options)))
-      // Disposal owns termination: sibling starts can still be settling after
-      // the containing tree has gone away, but their failures no longer
-      // describe a live update to roll back.
-      if (this.ctx.fiber.uid === null) return
-      const failures = outcomes
-        .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
-        .map(outcome => outcome.reason)
-      if (failures.length === 1) throw failures[0]
-      if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply')
-      for (const id of Object.keys(oldMap)) {
-        if (!newMap[id]) await this.remove(id, true)
-      }
-      this.data = config
-    } catch (error) {
-      const rollbackErrors: unknown[] = []
-      for (const id of Object.keys(newMap).reverse()) {
-        if (oldMap[id]) continue
-        try {
-          await this.remove(id, true)
-        } catch (rollbackError) {
-          rollbackErrors.push(rollbackError)
-        }
-      }
-      for (const options of oldConfig) {
-        try {
-          await this.create(options)
-        } catch (rollbackError) {
-          rollbackErrors.push(rollbackError)
-        }
+    // update inner plugins
+    const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[]
+    await Promise.all(ids.map(async (id) => {
+      if (newMap[id]) {
+        await this.create(newMap[id]).catch((error) => {
+          this.ctx.logger.error(error)
+        })
+      } else {
+        this.remove(id)
       }
-      this.data = oldConfig
-      if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed')
-      throw error
-    }
+    }))
   }
 
-  async stop() {
+  stop() {
     for (const options of this.data) {
-      await this.remove(options.id, true)
+      this.remove(options.id, true)
     }
   }
 }
@@ -119,7 +78,9 @@ export class Group extends EntryGroup {
 
   constructor(public ctx: Context, public config: EntryOptions[]) {
     super(ctx, ctx.fiber.entry!.parent.tree)
-    ctx.on('internal/update', config => this.update(config))
+    ctx.on('internal/update', (config) => {
+      this.update(config)
+    })
   }
 
   async* [Service.init]() {

+ 2 - 2
vendor/loader/src/config/isolate.ts

@@ -93,7 +93,7 @@ export default function isolate(ctx: Context) {
     entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate])
   })
 
-  ctx.on('loader/patch-context', async (entry, next) => {
+  ctx.on('loader/patch-context', (entry, next) => {
     // step 1: generate new isolate map
     const newMap: Dict<symbol> = Object.create(entry.parent.ctx[Context.isolate])
     for (const name of Object.keys(entry.options.isolate ?? {})) {
@@ -126,7 +126,7 @@ export default function isolate(ctx: Context) {
     swap(entry.ctx[Context.intercept], entry.options.intercept)
 
     // step 4: reload fiber
-    await next()
+    next()
 
     // step 5: replace service impl
     for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) {

+ 10 - 43
vendor/loader/src/config/tree.ts

@@ -39,27 +39,12 @@ export abstract class EntryTree {
       .filter(isNonNullable)
   }
 
-  /**
-   * Wait until this tree has no active import or lifecycle tasks.
-   * @throws a settled fiber failure, or an aggregate when several fibers failed.
-   */
+  /** Wait until this tree has no pending import or lifecycle tasks. */
   async await() {
     while (true) {
       const tasks = this.getTasks()
-      if (tasks.length) {
-        await Promise.allSettled(tasks)
-        continue
-      }
-      const outcomes = await Promise.allSettled(
-        [...this.entries()].map(entry => entry._await()),
-      )
-      const failures = outcomes
-        .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
-        .map(outcome => outcome.reason)
-      if (failures.length === 1) throw failures[0]
-      if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed')
-      this.ctx.reflect.notify(['loader'])
-      if (!this.getTasks().length) return
+      if (!tasks.length) return
+      await Promise.allSettled(tasks)
     }
   }
 
@@ -96,17 +81,15 @@ export abstract class EntryTree {
   /** Create an entry in the root group or a nested group. */
   async create(options: Omit<EntryOptions, 'id'>, parent: string | null = null, position = Infinity) {
     const group = this.resolveGroup(parent)
-    const id = await group.create(options)
-    const entry = this.resolve(id)
-    group.data.splice(position, 0, entry.options)
+    group.data.splice(position, 0, options as EntryOptions)
     group.tree.write()
-    return id
+    return group.create(options)
   }
 
   /** Stop and remove an entry from its parent group. */
-  async remove(id: string) {
+  remove(id: string) {
     const entry = this.resolve(id)
-    await entry.parent.remove(id)
+    entry.parent.remove(id)
     entry.parent.tree.write()
   }
 
@@ -114,31 +97,15 @@ export abstract class EntryTree {
   async update(id: string, options: Omit<EntryOptions, 'id' | 'name'>, parent?: string | null, position?: number) {
     const entry = this.resolve(id)
     const source = entry.parent
-    const sourceIndex = source.data.indexOf(entry.options)
-    let target = source
     if (parent !== undefined) {
-      target = this.resolveGroup(parent)
+      const target = this.resolveGroup(parent)
       source.unlink(entry.options)
       target.data.splice(position ?? Infinity, 0, entry.options)
+      target.tree.write()
       entry.parent = target
     }
-    try {
-      await entry.update(options, false, true)
-    } catch (error) {
-      if (parent !== undefined) {
-        target.unlink(entry.options)
-        source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options)
-        entry.parent = source
-        try {
-          await entry.update({}, false, true)
-        } catch (rollbackError) {
-          throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`)
-        }
-      }
-      throw error
-    }
     source.tree.write()
-    if (target !== source) target.tree.write()
+    return entry.update(options, false, true)
   }
 
   /** Import a plugin module from a specifier or `cordis:` builtin. */

+ 4 - 7
vendor/loader/src/index.ts

@@ -26,7 +26,7 @@ declare module '@deepseek-ai/cordis' {
     'loader/config-update'(): void
     'loader/entry-init'(entry: Entry): void
     'loader/partial-dispose'(entry: Entry, legacy: Partial<EntryOptions>, active: boolean): void
-    'loader/patch-context'(entry: Entry, next: () => void | Promise<void>): void | Promise<void>
+    'loader/patch-context'(entry: Entry, next: () => void): void
   }
 
   interface Context {
@@ -100,12 +100,12 @@ export class Loader extends EntryTree {
       return interpolate(this.ctx, config)
     }, { global: true })
 
-    ctx.on('internal/update', async function (config, noSave, next) {
+    ctx.on('internal/update', function (config, noSave, next) {
       if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()
-      await next()
       const unparse = this.runtime?.Config?.['simplify']
       this.entry.options.config = unparse ? unparse(config) : config
       this.entry.parent.tree.write()
+      return next()
     }, { global: true, prepend: true })
 
     ctx.on('internal/update', function (config, _, next) {
@@ -143,12 +143,9 @@ export class Loader extends EntryTree {
       const treeOwner = fiber.entry.parent.tree.ctx.fiber
       if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return
 
-      // case 6: Loader is replacing or removing this exact fiber
-      if (fiber.entry._disposing) return
-
       this.showLog(fiber.entry, 'unload')
 
-      // case 7: fiber is disposed by loader behavior
+      // case 6: fiber is disposed by loader behavior
       // such as inject checker, config file update, ancestor group disable
       if (fiber.entry.disabled) return
 

+ 0 - 9
vendor/schemastery/package.json

@@ -14,15 +14,6 @@
   "main": "lib/index.cjs",
   "module": "lib/index.mjs",
   "types": "lib/types/index.d.ts",
-  "exports": {
-    ".": {
-      "types": "./lib/types/index.d.ts",
-      "import": "./lib/index.mjs",
-      "require": "./lib/index.cjs"
-    },
-    "./src/*": "./src/*",
-    "./package.json": "./package.json"
-  },
   "files": [
     "lib/index.mjs",
     "lib/index.cjs",