Kaynağa Gözat

fix(boot): commit a recomposition only once the root include accepts it

`ProfileRuntime.recompose({ reloadBundles: true })` replaced its profile
before the Loader had accepted the update, so a rejected recomposition left
`current`, `layers`, and `originOf` describing a stack that never mounted.
The user patch watchers took a second path through the launcher's
`composeLive`, which wrote the new conflicts into the failure registry
before the update and kept composing from the runtime's stale profile.
Conflict records shared the registry with row failures under a fabricated
entry id and a group id that meant three different things, so the plugin
inventory attributed a conflict to the layer that won the id, and a bundle
disabled after failing kept its import and apply records forever.

The runtime now holds one committed composition — profile, stack ownership,
conflicts — and publishes a candidate only after `entry.update` resolves;
`originOf` reads the committed owners instead of recomputing them. The
watchers call `recompose` through a `reapply` callback, so every live change
goes through the one entry point. Conflicts live on the runtime with their
messages and never enter `pluginFailures`, whose records name rows that
reached the Loader; the inventory lists them under the layer that lost. A
contained group clears its rows' records when it unmounts.
Yichen Jiang 2 hafta önce
ebeveyn
işleme
0caceb2ced

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
-2026-09-04-external-bundles-as-contained-groups.md: 4f5305177419abfa38054307dac4c29249fe27b8
-2026-09-04-external-bundles-as-contained-groups.zh.md: e4a35e4801244e3f40362ace9da10730396792ff
+2026-09-04-external-bundles-as-contained-groups.md: 3319394d56630eb90dbce18f54483e22ad8577a5
+2026-09-04-external-bundles-as-contained-groups.zh.md: f439f96f01827ae95ebe2cbe7f94a1acfe833a27

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md

@@ -12,15 +12,15 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 **Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, inserted empty, followed by the bundle's patches in the order written: each root insert is re-targeted into that group, every insert into one built-in group lands in one contained wrapper group nested inside the target, and id-targeted patches pass through, reported as overrides when they address rows the bundle did not insert. Keeping the written order is what lets a patch that replaces a group's config and then appends to it mean the same thing under both stages. `/` rather than `:` in the group id because `:` is the Loader's nested-id separator.
 
-**Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot; a contained bundle that declares an id another layer owns, or declares one of its own ids twice, is left out whole; a user-layer insert of a taken id is dropped. Each row left out is a `conflict` record in `pluginFailures` — printed on stderr at boot, replaced on every recomposition, shown per package in the plugin list — and boot, live recomposition, and `--dump-config` compose through the one function, which renders each contained layer once and returns the patches, the owner of every id, and the conflicts together.
+**Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot; a contained bundle that declares an id another layer owns, or declares one of its own ids twice, is left out whole; a user-layer insert of a taken id is dropped. The rows left out are the composition's conflicts, each carrying its message: printed on stderr at boot, held by `ProfileRuntime` as part of the committed composition, and shown per package in the plugin list. They never enter `pluginFailures`, whose records name rows that reached the Loader. Boot, live recomposition, and `--dump-config` compose through the one function, which renders each contained layer once and returns the patches, the owner of every id, and the conflicts together.
 
-**The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape.
+**The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. When the group unmounts — its bundle disabled or uninstalled — it drops its rows' records, so no failure outlives the composition that produced it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape.
 
 **`stage: boot` is the explicit opt-out.** A bundle whose rows provide a service built-in rows inject declares `dsh.bundle.stage: boot` in its manifest, or the deployer sets `dsh.profile.stages` in the profile manifest, which wins; such a layer mounts unwrapped with fatal semantics. An unknown stage value fails profile loading.
 
 **Installed and enabled are two facts.** `reconcileInstalledBundles` no longer appends every bundle-declaring dependency to `dsh.profile.bundles` unconditionally; `autoEnable` keeps the CLI's install-and-enable semantics, and `enableBundle`/`disableBundle` are the manifest operations a plugin manager calls. `dependencies` records the install, `bundles` the enabled layers.
 
-**Provenance is a launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the booted profile, attributes each row to the layer that owns its id (`originOf`), reads which rows the user patch files disable with a literal `disabled: true`, and recomposes the tree through the root include — the same path the patch watchers take. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing rows the registry alone knows.
+**Provenance and recomposition are one launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the committed composition — the profile, the owner of every row id (`originOf`), and the conflicts — reads which rows the user patch files disable with a literal `disabled: true`, and is the one entry point that recomposes the tree: it composes a candidate, applies it through the root include, and publishes the candidate only once the include accepted it, so a rejected update leaves the facts describing the tree still running. The patch watchers call its `recompose` instead of composing themselves. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing the conflicts and the rows the registry alone knows.
 
 ## Alternatives considered
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md

@@ -12,15 +12,15 @@ Status: implemented
 
 **每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,先空着插入,随后按书写顺序跟着组合包自己的 patch:根级插入改为插进这个组,插入同一个内置组的行全部落进目标组内嵌套的同一个受控包装组,按 id 定位的 patch 原样通过,指向它没有插入的行时报告为覆盖。保持书写顺序,才能让"先替换某个组的 config 再向它追加"这样的 patch 在两种 stage 下含义一致。组 id 用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
 
-**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;受控组合包声明了别的层已占有的 id、或把自己的某个 id 声明了两次时整层排除;用户层插入已被占用的 id 时该行丢弃。每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录——启动时打到 stderr,每次重组时替换,在插件列表里按包显示——启动、运行时重组与 `--dump-config` 走同一个函数,它把每个受控层只渲染一次,并一并返回 patch、每个 id 的归属与冲突。
+**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;受控组合包声明了别的层已占有的 id、或把自己的某个 id 声明了两次时整层排除;用户层插入已被占用的 id 时该行丢弃。被排除的行就是这次组合的冲突,每条自带消息:启动时打到 stderr,由 `ProfileRuntime` 作为已提交组合的一部分持有,在插件列表里按包显示。它们从不进入 `pluginFailures`,那里的记录只指真正到达 Loader 的行。启动、运行时重组与 `--dump-config` 走同一个函数,它把每个受控层只渲染一次,并一并返回 patch、每个 id 的归属与冲突。
 
-**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
+**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。组卸载时——它的组合包被停用或卸载——会丢掉自己各行的记录,因此没有失败会比产生它的组合活得更久。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
 
 **`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、按致命语义挂载。未知的 stage 值让 profile 加载失败。
 
 **安装与启用是两件事。** `reconcileInstalledBundles` 不再无条件把每个声明了组合包的依赖追加进 `dsh.profile.bundles`;`autoEnable` 保留 CLI 装即启用的语义,`enableBundle`/`disableBundle` 是插件管理器调用的 manifest 操作。`dependencies` 记录安装,`bundles` 记录已启用的层。
 
-**来源是 launcher 的服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已启动的 profile,把每一行归属到占有其 id 的层(`originOf`),读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并经根 include 重新组合整棵树——patch 监视器走的正是同一条路。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出只有注册表知道的行。
+**来源与重组是同一个 launcher 服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已提交的组合——profile、每个行 id 的归属(`originOf`)与冲突——读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并且是重组整棵树的唯一入口:它先组合候选结果,经根 include 应用,只有 include 接受之后才发布候选结果,因此被拒的更新留下的事实仍然描述正在运行的树。patch 监视器调用它的 `recompose`,不再自己组合。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出冲突以及只有注册表知道的行。
 
 ## 考虑过的替代方案
 

+ 12 - 21
apps/cli/src/profile-boot.ts

@@ -29,7 +29,6 @@ import {
   loadProfile,
   PROFILE_PATCH_FILENAME,
   ProfileRuntime,
-  recordRowConflicts,
   rootIncludeEntry,
   warnNestedFiberFailures,
   watchUserPatches,
@@ -275,17 +274,17 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     const stack = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...composed.overlays])
     return { ...stack, patches: structuredClone(stack.patches) }
   }
-  // Once the profile runtime is mounted its profile is the current one: a
-  // bundle enabled since boot lives only there. The watcher path has no
-  // runtime to record conflicts on beyond the registry the runtime shares, so
-  // it records them itself once the tree accepted the update.
-  const composeLive = (): PatchOptions[] => {
-    const stack = composeFor(app.runtime?.current ?? composed.profile)
-    if (app.current !== undefined) recordRowConflicts(app.current, stack.conflicts)
-    return stack.patches
+  // Every recomposition after boot — a watched user file, a bundle enabled
+  // or installed — goes through the profile runtime, which publishes the
+  // profile, the row ownership, and the conflicts only once the tree
+  // accepted the update.
+  const reapply = async (): Promise<void> => {
+    const runtime = app.runtime
+    if (runtime === undefined) throw new Error(`${NAME}: user patch reload needs the profile runtime`)
+    await runtime.recompose()
   }
   for (const conflict of composed.stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
-  // Cloned for the same insert-aliasing reason as composeLive: the boot
+  // Cloned for the same insert-aliasing reason as composeFor: the boot
   // application must not mutate the objects later reloads recompose from.
   const ctx = await boot(NAME, rootConfig, structuredClone(composed.stack.patches), (hostCtx) => {
     app.current = hostCtx
@@ -307,9 +306,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
   if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
     warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
-    recordRowConflicts(ctx, composed.stack.conflicts)
     await ctx.plugin(ProfileRuntime, {
       profile: composed.profile,
+      stack: composed.stack,
       loadProfile: () => prepareProfile(options.profile),
       compose: composeFor,
       rootEntry: () => rootIncludeEntry(ctx),
@@ -343,16 +342,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
         }
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
       }
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: composed.profile.patchPath,
-        compose: composeLive,
-      })
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: homePatchPath(),
-        compose: composeLive,
-      })
+      await watchUserPatches(ctx, { binName: NAME, filename: composed.profile.patchPath, reapply })
+      await watchUserPatches(ctx, { binName: NAME, filename: homePatchPath(), reapply })
     } catch (error) {
       suppressShutdownError(ctx, signalShutdown.signal, error)
     }

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: bfadab1465b0fbae751da380307e5227ec508606
-README.zh.md: 4979d7cb77cd18590b26213aef72f2d0fcb71a43
+README.md: 881b8d1acc3197a218e6e6dca1c4c6be4fb78e07
+README.zh.md: faffc8acb38c0b3510fec75be22af36d7776f2f6

+ 3 - 3
packages/boot/app-boot/README.md

@@ -60,7 +60,7 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>` with the ids its patch declares, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled. Row ids share one namespace across the stack: built-in layers own theirs first, an external bundle that declares an id another layer already owns, or declares one of its own ids twice, is left out whole and reported on stderr and in the plugin list, and a user-layer insert of a taken id is dropped and reported the same way.
 
-After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one a runtime bundle enable or install uses. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits.
+After the tree is up the launcher provides `ctx.profileRuntime`, which holds the composition the tree runs — the profile, the layer that owns each row, and the rows the composition left out — reads which rows the user patch files disable, and is the one entry point that recomposes the tree: the patch watchers and a runtime bundle enable or install all call it, and a rejected update leaves its facts describing the tree still running. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits.
 
 ### Previewing the effective configuration
 
@@ -90,7 +90,7 @@ This section explains how the outcomes above are realized and points at the code
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
+- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that unmounts drops its rows' records, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
 - **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function.
 - **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
 - **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
@@ -111,7 +111,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 | [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, overrides) and the manifest operations behind install, enable, and disable |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | Row-id ownership across the stack: `claimLayerIds`, `composeProfileStack`, conflict records |
 | [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry |
-| [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: profile facts, row provenance, user-disabled rows, recomposition |
+| [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: the committed composition (profile, row provenance, conflicts), user-disabled rows, recomposition |
 | [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache |
 | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. |
 

+ 3 - 3
packages/boot/app-boot/README.zh.md

@@ -60,7 +60,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,id 保持它的 patch 所声明的样子,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。行 id 在整叠层里共用一个命名空间:内置层先占有自己的 id,外部组合包若声明了别的层已占有的 id,或把自己的某个 id 声明了两次,就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
 
-树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,运行时启用或安装组合包也走它。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
+树起来之后 launcher 提供 `ctx.profileRuntime`:它持有树正在运行的组合——profile、每一行的归属层、被组合排除的行——读取用户 patch 文件停用了哪些行,并且是重组整棵树的唯一入口:patch 监视器、运行时启用或安装组合包都调用它,被拒的更新留下的事实仍然描述正在运行的树。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
 
 ### 预览生效配置
 
@@ -90,7 +90,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
+- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组卸载时丢掉自己各行的记录,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
 - **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
@@ -111,7 +111,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 | [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、覆盖报告)与安装、启用、停用背后的 manifest 操作 |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
 | [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
-| [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:profile 事实、行来源、用户停用的行、重新组合 |
+| [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:已提交的组合(profile、行来源、冲突)、用户停用的行、重新组合 |
 | [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 

+ 18 - 19
packages/boot/app-boot/src/compose-stack.ts

@@ -42,6 +42,8 @@ export interface RowConflict {
    * layer's label, or the losing layer itself when it declares the id twice.
    */
   readonly declaredBy: string
+  /** The reason as diagnostics and the plugin list state it, without the layer that lost. */
+  readonly message: string
 }
 
 /** The stack as the root include should mount it, with what it left out. */
@@ -68,6 +70,15 @@ export interface LayerOwnership {
   readonly composed: Map<string, ComposedExternalLayer>
 }
 
+/** One conflict with its message: the id's other declarer, or the losing layer itself declaring it twice. */
+function rowConflict(fields: Omit<RowConflict, 'message'>): RowConflict {
+  const id = JSON.stringify(fields.rowId)
+  const message = fields.declaredBy === fields.layer
+    ? `row ${id} is declared twice by ${fields.layer}`
+    : `row ${id} is already declared by ${fields.declaredBy}`
+  return { ...fields, message }
+}
+
 /** The ids one inserted row carries: its own and, for a group, its children's. */
 function rowIds(row: EntryOptions): string[] {
   const ids: string[] = []
@@ -109,11 +120,13 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
     const { packageName } = layer
     const composition = composeExternalLayer(layer)
     const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
-      { rowId, moduleName, layer: packageName, packageName, declaredBy: packageName }
+      rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: packageName })
     ))
     for (const [rowId, moduleName] of composition.rows) {
       const owner = owners.get(rowId)
-      if (owner !== undefined) conflicts.push({ rowId, moduleName, layer: packageName, packageName, declaredBy: owner.packageName })
+      if (owner !== undefined) {
+        conflicts.push(rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: owner.packageName }))
+      }
     }
     if (conflicts.length > 0) {
       skipped.set(packageName, conflicts)
@@ -172,7 +185,7 @@ export function composeProfileStack(
         const ids = rowIds(row)
         const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
         if (taken?.[1] !== undefined) {
-          conflicts.push({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] })
+          conflicts.push(rowConflict({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] }))
           continue
         }
         for (const id of ids) claimed.set(id, userLayer.label)
@@ -192,27 +205,13 @@ export function composeProfileStack(
   }
 }
 
-/**
- * The reason one conflict states: the layer that already declares the id, or
- * the losing layer itself declaring it twice.
- * @param conflict - the conflict to describe.
- * @returns the reason clause, without the layer that lost.
- */
-export function describeRowConflict(conflict: RowConflict): string {
-  const id = JSON.stringify(conflict.rowId)
-  return conflict.declaredBy === conflict.layer
-    ? `row ${id} is declared twice by ${conflict.layer}`
-    : `row ${id} is already declared by ${conflict.declaredBy}`
-}
-
 /**
  * One diagnostic line for a conflict, as boot and the config dump print it.
  * @param conflict - the conflict to describe.
  * @returns the line, without a binary-name prefix.
  */
 export function formatRowConflict(conflict: RowConflict): string {
-  const reason = describeRowConflict(conflict)
   return conflict.packageName === undefined
-    ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${reason}`
-    : `bundle ${conflict.packageName} left out — ${reason}`
+    ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${conflict.message}`
+    : `bundle ${conflict.packageName} left out — ${conflict.message}`
 }

+ 19 - 42
packages/boot/app-boot/src/contained-group.ts

@@ -10,14 +10,9 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { describeRowConflict, type RowConflict } from './compose-stack.ts'
-import { bundleGroupId } from './external-bundles.ts'
 
-/**
- * The lifecycle step at which a contained row failed; `conflict` is a row the
- * composition left out because another layer already declares its id.
- */
-export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'conflict' | 'unknown'
+/** The lifecycle step at which a contained row failed. */
+export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown'
 
 /** One recorded failure of a row inside a contained group. */
 export interface ContainedFailure {
@@ -27,10 +22,8 @@ export interface ContainedFailure {
   readonly rowId: string
   /** The module specifier the row named. */
   readonly moduleName: string
-  /** The contained group the row belongs to; for a conflict, the group the bundle would have mounted, or the user layer's label. */
+  /** The contained group the row belongs to, as the tree names it. */
   readonly groupId: string
-  /** The bundle the row belongs to, for a record made before any group mounted (a composition conflict). */
-  readonly packageName?: string
   /** Which lifecycle step failed. */
   readonly stage: ContainedFailureStage
   /** The failure text, with the Loader's per-row wrapper folded in. */
@@ -39,8 +32,10 @@ export interface ContainedFailure {
 
 /**
  * Failures recorded by contained groups of one runtime. Rows are keyed by
- * their tree-wide id; recording a row again replaces its earlier record, and a
- * row that later mounts clears it.
+ * their tree-wide id; recording a row again replaces its earlier record, a
+ * row that later mounts clears it, and a group that unmounts clears its rows'.
+ * Rows the composition left out never reach a group and are not recorded
+ * here; `ProfileRuntime.conflicts` holds them.
  */
 export class ContainedFailureRegistry {
   private readonly failures = new Map<string, ContainedFailure>()
@@ -62,12 +57,12 @@ export class ContainedFailureRegistry {
   }
 
   /**
-   * Forget every record of one stage, before the stage's records are remade.
-   * @param stage - the stage whose records to drop.
+   * Forget every record of one contained group, when the group unmounts.
+   * @param groupId - the group's tree-wide id.
    */
-  clearStage(stage: ContainedFailureStage): void {
+  clearGroup(groupId: string): void {
     for (const [entryId, failure] of this.failures) {
-      if (failure.stage === stage) this.failures.delete(entryId)
+      if (failure.groupId === groupId) this.failures.delete(entryId)
     }
   }
 
@@ -112,7 +107,9 @@ function stageOf(message: string): ContainedFailureStage {
  * A group that contains its rows' startup failures. `create()` is the one
  * per-row step `EntryGroup.update` awaits, so catching there is what turns a
  * row failure from a group rejection into a record: the group activates, the
- * failed row is absent from the tree, and the record names it.
+ * failed row is absent from the tree, and the record names it. When the group
+ * unmounts — its bundle disabled or uninstalled — its rows' records go with
+ * it, so no failure outlives the composition that produced it.
  */
 export class ContainedGroup extends Group {
   override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
@@ -144,6 +141,11 @@ export class ContainedGroup extends Group {
     }
   }
 
+  override async stop(): Promise<void> {
+    await super.stop()
+    this.registry()?.clearGroup(this.groupId())
+  }
+
   /** The registry provided on the runtime root, if the boot glue provided one. */
   private registry(): ContainedFailureRegistry | undefined {
     return this.ctx.get('pluginFailures')
@@ -183,28 +185,3 @@ export function ensurePluginFailures(ctx: Context): ContainedFailureRegistry {
   ctx.root.provide('pluginFailures', registry)
   return registry
 }
-
-/**
- * Replace the registry's conflict records with the conflicts of one
- * composition: every earlier `conflict` record is dropped, so a conflict
- * resolved since (a bundle uninstalled, a user row renamed) disappears, and
- * each current one is recorded under an id no mounted row can carry.
- * @param ctx - any context of the runtime.
- * @param conflicts - the conflicts of the composition just applied.
- */
-export function recordRowConflicts(ctx: Context, conflicts: readonly RowConflict[]): void {
-  const registry = ensurePluginFailures(ctx)
-  registry.clearStage('conflict')
-  for (const conflict of conflicts) {
-    const groupId = conflict.packageName === undefined ? conflict.layer : bundleGroupId(conflict.packageName)
-    registry.record({
-      entryId: `conflict:${groupId}:${conflict.rowId}`,
-      rowId: conflict.rowId,
-      moduleName: conflict.moduleName,
-      groupId,
-      ...conflict.packageName === undefined ? {} : { packageName: conflict.packageName },
-      stage: 'conflict',
-      message: describeRowConflict(conflict),
-    })
-  }
-}

+ 13 - 14
packages/boot/app-boot/src/index.ts

@@ -51,7 +51,7 @@ export {
   type ProfileTemplate,
 } from './profile.ts'
 export {
-  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry, recordRowConflicts,
+  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry,
   type ContainedFailure, type ContainedFailureStage,
 } from './contained-group.ts'
 export {
@@ -60,7 +60,7 @@ export {
   type BundleReconciliation, type ComposedExternalLayer, type DuplicateRow,
 } from './external-bundles.ts'
 export {
-  claimLayerIds, composeProfileStack, describeRowConflict, formatRowConflict,
+  claimLayerIds, composeProfileStack, formatRowConflict,
   type ComposedStack, type LayerOwnership, type RowConflict, type StackUserLayer,
 } from './compose-stack.ts'
 export {
@@ -253,19 +253,19 @@ export interface UserPatchWatchOptions {
   /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
   filename: string
   /**
-   * Compose the full patch list for a fresh user-layer generation —
-   * the same composition the app booted with, so a reload can interleave the
-   * new user patches between app-owned layers (bundle layers below,
-   * overlays above). Identity when omitted: the user layer
-   * is the whole patch list.
+   * Re-apply the composition after the watched file changed. When omitted,
+   * the file's patches are re-read and mounted as the whole patch list. A
+   * launcher with a profile runtime passes its `recompose`, so the user layer
+   * is interleaved between the app-owned layers and every recomposition,
+   * watched or requested, goes through that one entry point.
    */
-  compose?: (userPatches: PatchOptions[]) => PatchOptions[]
+  reapply?: () => Promise<void>
 }
 
 /**
  * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
  * @param ctx - settled app context containing the root Include and an active HMR service.
- * @param options - diagnostic, file, and patch-composition inputs.
+ * @param options - diagnostic, file, and re-application inputs.
  * @returns an asynchronous disposer after the exact-path watcher is ready.
  * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
  */
@@ -273,24 +273,23 @@ export async function watchUserPatches(
   ctx: Context,
   options: UserPatchWatchOptions,
 ): Promise<() => Promise<void>> {
-  const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
+  const { binName, filename } = options
   const hmr = ctx.get('hmr')
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
-  const register = hmr.registerConfig(filename, async () => {
+  const reapply = options.reapply ?? (async (): Promise<void> => {
     // Re-read the include's non-patch options per refresh so a writer that
     // updates another option between refreshes is not silently reverted.
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
-    const userPatches = loadOptionalPatches(binName, filename) ?? []
-    const patches = compose(userPatches)
     await entry.update({
       config: {
         ...includeConfig,
-        patches,
+        patches: loadOptionalPatches(binName, filename) ?? [],
       },
     })
   })
+  const register = hmr.registerConfig(filename, reapply)
   try {
     return await register
   } catch (error) {

+ 45 - 38
packages/boot/app-boot/src/profile-runtime.ts

@@ -1,9 +1,13 @@
 /**
  * The `profileRuntime` service: the booted profile's facts and the one
  * recomposition entry point every live change to the host tree goes through —
- * user patch-file reloads, bundle enable/disable, and hot install. Before
- * this service the composition closure lived in the launcher and bundle
- * layers were frozen at boot, so nothing in the tree could learn which
+ * user patch-file reloads, bundle enable/disable, and hot install. A
+ * recomposition composes a candidate stack, applies it through the root
+ * include, and publishes the profile, the stack's id ownership, and its
+ * conflicts only once the include accepted it; a rejected update leaves the
+ * committed composition in place, which describes the tree still running.
+ * Before this service the composition closure lived in the launcher and
+ * bundle layers were frozen at boot, so nothing in the tree could learn which
  * profile it ran in or add a layer while running.
  * @module @deepseek-ai/dsh-app-boot/profile-runtime
  */
@@ -12,10 +16,9 @@ import { Context, Service } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type Include from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { claimLayerIds, type ComposedStack } from './compose-stack.ts'
-import { recordRowConflicts } from './contained-group.ts'
-import { isJsDisabled } from './external-bundles.ts'
 import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { ComposedStack, RowConflict } from './compose-stack.ts'
+import { isJsDisabled } from './external-bundles.ts'
 import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -39,6 +42,8 @@ export interface RowOrigin {
 export interface ProfileRuntimeOptions {
   /** The profile as booted. */
   profile: Profile
+  /** The stack the tree booted with, as `compose` rendered it for `profile`. */
+  stack: ComposedStack
   /** Re-read the profile from disk, re-resolving its bundle layers. */
   loadProfile: () => Profile
   /** The complete patch stack for a profile — bundle layers, user layers, overlays — with the rows it left out. */
@@ -49,44 +54,54 @@ export interface ProfileRuntimeOptions {
   readUserPatches: () => PatchOptions[]
 }
 
+/** The profile and stack the tree runs, published together once the root include accepted the stack. */
+interface CommittedComposition {
+  readonly profile: Profile
+  readonly stack: ComposedStack
+}
+
 /** Facts and recomposition of the booted profile. */
 export class ProfileRuntime extends Service {
-  private profile: Profile
-  private origins: Map<string, RowOrigin> | undefined
+  private committed: CommittedComposition
 
   constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) {
     super(ctx, 'profileRuntime')
-    this.profile = options.profile
+    this.committed = { profile: options.profile, stack: options.stack }
   }
 
-  /** The profile as currently composed; re-read by a `recompose({ reloadBundles: true })`. */
+  /** The profile as last composed; re-read by a `recompose({ reloadBundles: true })` the include accepted. */
   get current(): Profile {
-    return this.profile
+    return this.committed.profile
   }
 
   /** The profile name (`dsh --profile <name>`). */
   get profileName(): string {
-    return this.profile.name
+    return this.committed.profile.name
   }
 
   /** Absolute profile directory. */
   get dir(): string {
-    return this.profile.dir
+    return this.committed.profile.dir
   }
 
   /** Absolute path of the profile's own user patch file. */
   get patchPath(): string {
-    return this.profile.patchPath
+    return this.committed.profile.patchPath
   }
 
   /** Whether user patch files reload while the profile runs. */
   get patchReload(): ProfilePatchReload {
-    return this.profile.patchReload
+    return this.committed.profile.patchReload
   }
 
   /** The bundle layers currently composed, in application order. */
   get layers(): readonly ProfileLayer[] {
-    return this.profile.layers
+    return this.committed.profile.layers
+  }
+
+  /** The rows the current composition left out: bundles skipped over a row id and user inserts of taken ids. */
+  get conflicts(): readonly RowConflict[] {
+    return this.committed.stack.conflicts
   }
 
   /**
@@ -95,8 +110,13 @@ export class ProfileRuntime extends Service {
    * @returns the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).
    */
   originOf(rowId: string): RowOrigin | undefined {
-    this.origins ??= this.computeOrigins()
-    return this.origins.get(rowId)
+    const layer = this.committed.stack.owners.get(rowId)
+    if (layer === undefined) return undefined
+    return {
+      trust: layer.trust,
+      packageName: layer.packageName,
+      ...layer.version === undefined ? {} : { version: layer.version },
+    }
   }
 
   /**
@@ -119,8 +139,10 @@ export class ProfileRuntime extends Service {
    * as they stand now. The root Include re-applies the stack transactionally:
    * a row whose options changed is updated in place, a row that appeared is
    * created, a row that vanished is disposed, and a failure rolls the whole
-   * update back with the previous tree still running. The rows the stack left
-   * out replace the failure registry's conflict records once the update holds.
+   * update back with the previous tree still running. The candidate profile,
+   * its ownership, and its conflicts become the committed composition only
+   * once the update holds; until then, and after a rejection, `current`,
+   * `layers`, `originOf`, and `conflicts` keep describing the running tree.
    * @param options - `reloadBundles` re-reads the profile manifest first, so a
    * bundle enabled or installed since boot joins the stack.
    * @throws when the root include is not mounted, or the Loader rejected the update.
@@ -128,30 +150,15 @@ export class ProfileRuntime extends Service {
   async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
     const entry = this.options.rootEntry()
     if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
-    if (options.reloadBundles === true) {
-      this.profile = this.options.loadProfile()
-      this.origins = undefined
-    }
+    const profile = options.reloadBundles === true ? this.options.loadProfile() : this.committed.profile
+    const stack = this.options.compose(profile)
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
-    const stack = this.options.compose(this.profile)
     await entry.update({
       config: {
         ...includeConfig,
         patches: stack.patches,
       },
     })
-    recordRowConflicts(this.ctx, stack.conflicts)
-  }
-
-  private computeOrigins(): Map<string, RowOrigin> {
-    const origins = new Map<string, RowOrigin>()
-    for (const [id, layer] of claimLayerIds(this.profile.layers).owners) {
-      origins.set(id, {
-        trust: layer.trust,
-        packageName: layer.packageName,
-        ...layer.version === undefined ? {} : { version: layer.version },
-      })
-    }
-    return origins
+    this.committed = { profile, stack }
   }
 }

+ 25 - 49
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -2,18 +2,14 @@
  * Tree-wide row-id ownership across the profile stack: built-in layers claim
  * first and fail loud on a duplicate, an external bundle that collides is left
  * out and recorded, a bundle that repeats one of its own ids is left out the
- * same way, a user insert of a taken id is dropped, and the conflict records
- * replace the registry's earlier ones on every composition.
+ * same way, a user insert of a taken id is dropped, and every conflict
+ * carries its message.
  */
 
-import { afterEach, describe, expect, it } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import {
-  claimLayerIds, composeProfileStack, CONTAINED_GROUP_MODULE, ensurePluginFailures, formatRowConflict,
-  recordRowConflicts, type ProfileLayer,
-} from '../src/index.ts'
+import { claimLayerIds, composeProfileStack, CONTAINED_GROUP_MODULE, formatRowConflict, type ProfileLayer } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
 
@@ -28,11 +24,6 @@ const base = layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [
   { id: 'tools', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'bash' }] },
 ] }])
 
-const contexts: Context[] = []
-afterEach(async () => {
-  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
-})
-
 describe('claimLayerIds', () => {
   it('lets built-in layers own their ids, including group children, before any external layer', () => {
     const ext = layer('ext', 'external', [{ insert: [{ id: 'tool-bash', name: 'ext' }] }])
@@ -40,7 +31,10 @@ describe('claimLayerIds', () => {
     expect(owners.get('tool-bash')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(owners.get('tools')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(skipped.get('ext')).toEqual([
-      { rowId: 'tool-bash', moduleName: 'ext', layer: 'ext', packageName: 'ext', declaredBy: '@deepseek-ai/dsh-base' },
+      {
+        rowId: 'tool-bash', moduleName: 'ext', layer: 'ext', packageName: 'ext', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "tool-bash" is already declared by @deepseek-ai/dsh-base',
+      },
     ])
   })
 
@@ -56,7 +50,7 @@ describe('claimLayerIds', () => {
     const clean = layer('clean', 'external', [{ insert: [{ id: 'y', name: 'clean' }] }])
     const { owners, skipped, composed } = claimLayerIds([base, stutter, clean])
     expect(skipped.get('stutter')).toEqual([
-      { rowId: 'x', moduleName: 'stutter/b', layer: 'stutter', packageName: 'stutter', declaredBy: 'stutter' },
+      { rowId: 'x', moduleName: 'stutter/b', layer: 'stutter', packageName: 'stutter', declaredBy: 'stutter', message: 'row "x" is declared twice by stutter' },
     ])
     expect(owners.has('x')).toBe(false)
     expect(owners.get('y')?.packageName).toBe('clean')
@@ -101,9 +95,15 @@ describe('composeProfileStack', () => {
     expect(stack.patches).toEqual(stack.layers.flatMap(current => current.patches))
     expect(stack.skippedBundles).toEqual([])
     expect(stack.conflicts).toEqual([
-      { rowId: 'ext-tool', moduleName: 'clash', layer: '/p/cordis.patch.yml', declaredBy: 'ext' },
-      { rowId: 'tool-bash', moduleName: 'cordis:group', layer: '/p/cordis.patch.yml', declaredBy: '@deepseek-ai/dsh-base' },
-      { rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml' },
+      { rowId: 'ext-tool', moduleName: 'clash', layer: '/p/cordis.patch.yml', declaredBy: 'ext', message: 'row "ext-tool" is already declared by ext' },
+      {
+        rowId: 'tool-bash', moduleName: 'cordis:group', layer: '/p/cordis.patch.yml', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "tool-bash" is already declared by @deepseek-ai/dsh-base',
+      },
+      {
+        rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml',
+        message: 'row "mine" is already declared by /p/cordis.patch.yml',
+      },
     ])
   })
 
@@ -113,7 +113,10 @@ describe('composeProfileStack', () => {
     expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base'])
     expect(stack.skippedBundles).toEqual(['clash'])
     expect(stack.conflicts).toEqual([
-      { rowId: 'settings', moduleName: 'clash', layer: 'clash', packageName: 'clash', declaredBy: '@deepseek-ai/dsh-base' },
+      {
+        rowId: 'settings', moduleName: 'clash', layer: 'clash', packageName: 'clash', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "settings" is already declared by @deepseek-ai/dsh-base',
+      },
     ])
   })
 
@@ -125,37 +128,10 @@ describe('composeProfileStack', () => {
 
 describe('formatRowConflict', () => {
   it('names the bundle left out, or the user layer whose insert was skipped', () => {
-    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: 'pkg', packageName: 'pkg', declaredBy: 'base' }))
+    const message = 'row "x" is already declared by base'
+    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: 'pkg', packageName: 'pkg', declaredBy: 'base', message }))
       .toBe('bundle pkg left out — row "x" is already declared by base')
-    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: '/p/cordis.patch.yml', declaredBy: 'base' }))
+    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: '/p/cordis.patch.yml', declaredBy: 'base', message }))
       .toBe('/p/cordis.patch.yml: insert of m skipped — row "x" is already declared by base')
-    expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: 'pkg', packageName: 'pkg', declaredBy: 'pkg' }))
-      .toBe('bundle pkg left out — row "x" is declared twice by pkg')
-  })
-})
-
-describe('recordRowConflicts', () => {
-  it('replaces the conflict records of the previous composition and keeps other stages', () => {
-    const ctx = new Context()
-    contexts.push(ctx)
-    const registry = ensurePluginFailures(ctx)
-    registry.record({ entryId: 'include:ext/bad', rowId: 'bad', moduleName: 'ext', groupId: 'include:bundle/ext', stage: 'apply', message: 'boom' })
-    recordRowConflicts(ctx, [
-      { rowId: 'hello', moduleName: 'second', layer: 'second', packageName: 'second', declaredBy: 'first' },
-      { rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml' },
-    ])
-    expect(registry.list()).toEqual([
-      expect.objectContaining({ entryId: 'include:ext/bad', stage: 'apply' }),
-      {
-        entryId: 'conflict:bundle/second:hello', rowId: 'hello', moduleName: 'second', groupId: 'bundle/second',
-        packageName: 'second', stage: 'conflict', message: 'row "hello" is already declared by first',
-      },
-      {
-        entryId: 'conflict:/home/cordis.patch.yml:mine', rowId: 'mine', moduleName: 'twice', groupId: '/home/cordis.patch.yml',
-        stage: 'conflict', message: 'row "mine" is already declared by /p/cordis.patch.yml',
-      },
-    ])
-    recordRowConflicts(ctx, [])
-    expect(registry.list().map(failure => failure.stage)).toEqual(['apply'])
   })
 })

+ 27 - 0
packages/boot/app-boot/tests/contained-group.spec.ts

@@ -167,6 +167,33 @@ describe('cordis:contained-group', () => {
     expect(registry.get('include:ext/flaky')?.message).toContain('flaky on reload')
   })
 
+  it('forgets its rows\' records when the group unmounts', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/bad
+      name: cordis:throws
+    - id: ext/ok
+      name: cordis:good
+- id: bundle/other
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: other/bad
+      name: cordis:throws
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.list().map(failure => failure.entryId).sort()).toEqual(['include:ext/bad', 'include:other/bad'])
+    // The group's row id keys the tree store; `Entry.id` carries the include prefix.
+    const group = ctx.loader.resolve('include:bundle/ext')
+    await group.parent.remove(group.options.id)
+    expect([...ctx.loader.entries()].some(entry => entry.id === 'include:ext/ok')).toBe(false)
+    expect(registry.list().map(failure => failure.entryId)).toEqual(['include:other/bad'])
+  })
+
   it('provides one registry per runtime', async () => {
     const ctx = new Context()
     contexts.push(ctx)

+ 22 - 14
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
+import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
 const contexts: Context[] = []
 afterEach(async () => {
@@ -28,17 +28,21 @@ async function harness(
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   contexts.push(ctx)
+  // The conflicts, when given, belong to the reloaded profile only.
   const compose = vi.fn((current: Profile): ComposedStack => {
     const patches = [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]
     return {
       patches,
       layers: [{ label: 'stack', patches }],
-      conflicts: options.conflicts ?? [],
+      owners: claimLayerIds(current.layers).owners,
+      conflicts: current === options.reloaded ? options.conflicts ?? [] : [],
       skippedBundles: [],
     }
   })
+  const booted = profile(layers)
   await ctx.plugin(ProfileRuntime, {
-    profile: profile(layers),
+    profile: booted,
+    stack: compose(booted),
     loadProfile: () => options.reloaded ?? profile(layers),
     compose,
     rootEntry: options.rootEntry ?? (() => undefined),
@@ -99,32 +103,36 @@ describe('ProfileRuntime', () => {
     expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
   })
 
-  it('recomposes through the root include, optionally re-reading the profile first', async () => {
+  it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
     const update = vi.fn(async () => {})
     const entry = { options: { config: { path: 'file:///root/cordis.yml', patches: [{ id: 'old' }] } }, update } as unknown as Entry
     const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
-    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a' }]
-    const { ctx, runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
+    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a', message: 'row "x" is already declared by a' }]
+    const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
 
     await runtime.recompose()
     expect(compose).toHaveBeenLastCalledWith(expect.objectContaining({ layers: expect.any(Array) as ProfileLayer[] }))
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-1' }] } })
-    // The stack's conflicts become the registry's conflict records once the update holds.
-    expect(ensurePluginFailures(ctx).list()).toEqual([expect.objectContaining({ stage: 'conflict', rowId: 'x', packageName: 'late' })])
+    expect(runtime.conflicts).toEqual([])
 
     await runtime.recompose({ reloadBundles: true })
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
-    // Provenance follows the reloaded profile.
+    // Provenance and conflicts follow the reloaded profile once the update holds.
     expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
+    expect(runtime.conflicts).toEqual(conflicts)
   })
 
-  it('leaves the registry untouched when the root include rejects the update', async () => {
+  it('keeps the committed profile, provenance, and conflicts when the root include rejects the update', async () => {
     const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update: vi.fn(async () => { throw new Error('rejected') }) } as unknown as Entry
-    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a' }]
-    const { ctx, runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, conflicts })
-    await expect(runtime.recompose()).rejects.toThrow('rejected')
-    expect(ctx.get('pluginFailures')).toBeUndefined()
+    const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
+    const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a', message: 'row "x" is already declared by a' }]
+    const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
+    await expect(runtime.recompose({ reloadBundles: true })).rejects.toThrow('rejected')
+    expect(runtime.current.layers).toHaveLength(1)
+    expect(runtime.layers.map(current => current.packageName)).toEqual(['a'])
+    expect(runtime.originOf('bundle/b')).toBeUndefined()
+    expect(runtime.conflicts).toEqual([])
   })
 
   it('refuses to recompose before the root include is mounted', async () => {

+ 11 - 3
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -19,7 +19,7 @@ import {
   loadOptionalPatches,
   loadOverlayPatches,
   PROFILE_PATCH_FILENAME,
-  watchUserPatches,
+  watchUserPatches, rootIncludeEntry,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -405,7 +405,12 @@ describe('boot with user patches', () => {
     const dispose = await watchUserPatches(ctx, {
       binName: NAME,
       filename,
-      compose: userPatches => [...basePatches, ...userPatches],
+      reapply: async () => {
+        const entry = rootIncludeEntry(ctx)
+        if (entry === undefined) throw new Error('no root include')
+        const { patches: _previous, ...config } = entry.options.config as Include.Config
+        await entry.update({ config: { ...config, patches: [...basePatches, ...loadOptionalPatches(NAME, filename) ?? []] } })
+      },
     })
     try {
       writeFileSync(filename, '- id: noop\n  config:\n    value: live\n')
@@ -433,13 +438,16 @@ describe('boot with user patches', () => {
       expect(failures).toHaveLength(2)
       await settleChokidarChangeThrottle()
 
-      // Default compose: the user layer IS the whole patch list, so a
+      // Default re-application: the user layer IS the whole patch list, so a
       // fresh generation replaces the app-owned layer instead of stacking on it.
       await dispose()
       const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
       try {
         writeFileSync(filename, '- id: noop\n  config:\n    value: identity\n')
         await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
+        await settleChokidarChangeThrottle()
+        unlinkSync(filename)
+        await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'base', 'default-compose removal did not empty the patch list')
       } finally {
         await disposeDefault()
       }

+ 18 - 0
packages/host/plugin-inventory/src/index.ts

@@ -115,6 +115,24 @@ export class PluginInventoryGateway extends TypertRemoteService {
         failure: { stage: failure.stage, message: failure.message },
       })
     }
+    // A row the composition left out never reached the tree; the conflict
+    // names the layer that lost, so no lookup of the id's owner is needed.
+    if (runtime !== undefined) {
+      for (const conflict of runtime.conflicts) {
+        const version = runtime.layers.find(candidate => candidate.packageName === conflict.packageName)?.version
+        entries.push({
+          entryId: pluginEntryId(`conflict:${conflict.layer}:${conflict.rowId}`),
+          moduleName: conflict.moduleName,
+          enabled: true,
+          fiberPhase: 'failed',
+          trust: conflict.packageName === undefined ? 'builtin' : 'external',
+          ...conflict.packageName === undefined
+            ? {}
+            : { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },
+          failure: { stage: 'conflict', message: conflict.message },
+        })
+      }
+    }
     const presets = this.ctx.get('agentPresets')
     if (presets === undefined) return { entries }
     const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map(

+ 1 - 1
packages/host/plugin-inventory/src/types.ts

@@ -48,7 +48,7 @@ export interface PluginInventoryEntry {
   readonly package?: PluginPackageRef
   /** Present exactly when `enabled` is false. */
   readonly disabledBy?: PluginDisabledBy
-  /** Present for a row an isolated bundle failed to start. */
+  /** Present for a row an isolated bundle failed to start, or a row the composition left out. */
   readonly failure?: PluginFailure
 }
 

+ 22 - 2
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -97,7 +97,7 @@ 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 () => {
+  it('attributes rows to their bundle through the profile runtime and lists recorded failures and conflicts', 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' })
@@ -112,7 +112,13 @@ describe('PluginInventoryGateway', () => {
     ctx.provide('profileRuntime', {
       originOf: (rowId: string) => origins.get(rowId),
       userDisabledRowIds: () => new Set([off]),
-    } as Partial<ProfileRuntime> as never)
+      layers: [{ packageName: 'late', version: '9.9.9' }],
+      conflicts: [
+        { rowId: 'tool', moduleName: 'late', layer: 'late', packageName: 'late', declaredBy: 'ext', message: 'row "tool" is already declared by ext' },
+        { rowId: 'mine', moduleName: 'twice', layer: '/p/cordis.patch.yml', declaredBy: 'ext', message: 'row "mine" is already declared by ext' },
+        { rowId: 'x', moduleName: 'gone/x', layer: 'gone', packageName: 'gone', declaredBy: 'gone', message: 'row "x" is declared twice by gone' },
+      ],
+    } as unknown as ProfileRuntime)
     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.
@@ -128,6 +134,20 @@ describe('PluginInventoryGateway', () => {
       // 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' } },
+      // A row the composition left out is listed from the runtime's conflicts, under the layer that lost.
+      {
+        entryId: 'conflict:late:tool', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external',
+        package: { name: 'late', version: '9.9.9' }, failure: { stage: 'conflict', message: 'row "tool" is already declared by ext' },
+      },
+      {
+        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'builtin',
+        failure: { stage: 'conflict', message: 'row "mine" is already declared by ext' },
+      },
+      // A bundle the layer list no longer names keeps its package, without a version.
+      {
+        entryId: 'conflict:gone:x', moduleName: 'gone/x', enabled: true, fiberPhase: 'failed', trust: 'external',
+        package: { name: 'gone' }, failure: { stage: 'conflict', message: 'row "x" is declared twice by gone' },
+      },
     ])
   })