فهرست منبع

Merge branch 'feat/plugin-mgmt-2-manager' into feat/plugin-mgmt-3-settings

Yichen Jiang 3 هفته پیش
والد
کامیت
df41c01ab7

+ 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: 023fc1efb803348b054e0c0118ba50c4564fc9c1
-2026-09-04-external-bundles-as-contained-groups.zh.md: c86edb796f42e09052e05349cee0cb9bba19911d
+2026-09-04-external-bundles-as-contained-groups.md: de6ac509518ca0ccbebb630f1c0afdf16842d8c3
+2026-09-04-external-bundles-as-contained-groups.zh.md: 1a9e4d5a0a3977265c838650ef023e2ef272eddc

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

@@ -14,7 +14,7 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 **Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: a layer introduces the rows it inserts and the rows its config overrides set as a group's children; built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot, while a config override restating a row the same layer declared is that layer keeping its own child; 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, and a user override's rows are the user's own to restate. 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. A record names the contained group that isolates the row, whatever plain groups sit between. When the group updates, the records of rows its configuration dropped go; when it unmounts — its bundle disabled or uninstalled — all its rows' records go with it, 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.
+**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. A record names the contained group that isolates the row, whatever plain groups sit between. When the group updates, the records of rows its configuration dropped go; when it unmounts — its bundle disabled or uninstalled — all its rows' records go with it, 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. A pending row's record is dropped once its fiber activates in place — the service it waited for switched back on, say — since a wait that is over is not a failure to report. 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.
 

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

@@ -14,7 +14,7 @@ Status: implemented
 
 **行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:一层引入的行包括它插入的行和它的 config 覆盖设为某个组子行的行;内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败,而 config 覆盖重述同一层已声明的行只是这一层保留自己的子行;受控组合包声明了别的层已占有的 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 的行变成一条记录),内置行保留致命路径。挂起的行一旦其 fiber 就地激活——比如它等待的服务被重新打开——记录随即清除,因为已经结束的等待不是需要报告的失败。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
 
 **`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、按致命语义挂载。未知的 stage 值让 profile 加载失败。
 

+ 25 - 1
packages/boot/app-boot/src/contained-group.ts

@@ -14,6 +14,7 @@ import { visitRowTree } from './patch-rows.ts'
 
 /** Runtime mirror: FiberState is a cross-package const enum. */
 const FIBER_PENDING = 0 as FiberState.PENDING
+const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 
 /**
  * The diagnostic line for a fiber waiting on services, naming the ones its
@@ -49,7 +50,8 @@ 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, a
- * row that later mounts clears it, a group that updates drops the records of
+ * row that later mounts — re-created, or activated in place once the service
+ * it waited for appears — clears it, a group that updates drops the records of
  * rows it no longer configures, and a group that unmounts clears its rows'.
  * A record's `groupId` names the contained group that isolates the row, at
  * any nesting depth, so those two cleanups reach every row of a bundle.
@@ -145,6 +147,9 @@ function stageOf(error: unknown): ContainedFailureStage {
  * no failure outlives the composition that produced it.
  */
 export class ContainedGroup extends Group {
+  /** Whether the activation watcher is registered. */
+  private watching = false
+
   override async update(config: EntryOptions[]): Promise<void> {
     await super.update(config)
     const configured = new Set<string>()
@@ -163,6 +168,7 @@ export class ContainedGroup extends Group {
       const rowId = (options as EntryOptions).id
       const fiber = this.tree.store[rowId]?.fiber
       if (fiber !== undefined && fiber.state === FIBER_PENDING) {
+        this.watchActivation()
         this.registry()?.record({
           entryId: id,
           rowId,
@@ -204,6 +210,24 @@ export class ContainedGroup extends Group {
     this.registry()?.clearGroup(this.groupId())
   }
 
+  /**
+   * A row recorded as waiting comes to life in place once its service
+   * appears — a sibling row switched back on, say — with no `create` to
+   * clear the record, so the group watches fiber states for that moment.
+   * Registered once per group; the group's context disposal drops it.
+   */
+  private watchActivation(): void {
+    if (this.watching) return
+    this.watching = true
+    this.ctx.on('internal/status', (fiber: Fiber) => {
+      if (fiber.state !== FIBER_ACTIVE) return
+      const entryId = fiber.entry?.id
+      if (entryId === undefined) return
+      const registry = this.registry()
+      if (registry?.get(entryId)?.stage === 'inject-pending') registry.clear(entryId)
+    })
+  }
+
   /** The registry provided on the runtime root, if the boot glue provided one. */
   private registry(): ContainedFailureRegistry | undefined {
     return this.ctx.get('pluginFailures')

+ 22 - 1
packages/boot/app-boot/tests/contained-group.spec.ts

@@ -9,7 +9,7 @@
 import { mkdtempSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
@@ -97,6 +97,27 @@ describe('cordis:contained-group', () => {
     expect(registry.get('include:ext/waiting')?.message).toContain('neverReady')
   })
 
+  it('clears a waiting row\'s record once the service it waits for appears', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/waiting
+      name: cordis:pending
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.get('include:ext/waiting')?.stage).toBe('inject-pending')
+    // A fiber outside the tree — no entry, no record — changes state without touching the registry.
+    await ctx.plugin(() => {})
+    expect(registry.get('include:ext/waiting')?.stage).toBe('inject-pending')
+    // The service arrives in place, with no re-creation of the row: the fiber
+    // activates where it stands, and the record goes with the wait.
+    ctx.provide('neverReady', {})
+    await vi.waitFor(() => { expect(registry.get('include:ext/waiting')).toBeUndefined() })
+  })
+
   it('keeps recording a waiting row when a reload re-creates its group', async () => {
     const ctx = await boot(NAME, stage(`
 - id: bundle/ext