From 4d83c2c71fb956c79fa8031d57350c252271cb46 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 11:36:33 +0800 Subject: [PATCH] fix(boot): drop a row's recorded wait once its service appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contained row left pending is recorded as waiting for a service, but a live recomposition that brings the service back — a sibling row switched on again — activates the fiber in place, with no create() to clear the record, so the registry kept reporting a wait that was over. The group now watches fiber states once it has recorded a wait and clears the record when that row's fiber comes to life. --- ...rnal-bundles-as-contained-groups.i18n.yaml | 4 +-- ...04-external-bundles-as-contained-groups.md | 2 +- ...external-bundles-as-contained-groups.zh.md | 2 +- packages/boot/app-boot/src/contained-group.ts | 26 ++++++++++++++++++- .../app-boot/tests/contained-group.spec.ts | 23 +++++++++++++++- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml index 14fd144ee2..2249dd8089 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md index 023fc1efb8..de6ac50951 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md +++ b/.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. diff --git a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md index c86edb796f..1a9e4d5a0a 100644 --- a/.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md +++ b/.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 加载失败。 diff --git a/packages/boot/app-boot/src/contained-group.ts b/packages/boot/app-boot/src/contained-group.ts index 11f8edeab5..a6ea468cf2 100644 --- a/packages/boot/app-boot/src/contained-group.ts +++ b/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 { await super.update(config) const configured = new Set() @@ -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') diff --git a/packages/boot/app-boot/tests/contained-group.spec.ts b/packages/boot/app-boot/tests/contained-group.spec.ts index 879c9bf5f3..1dc245cc93 100644 --- a/packages/boot/app-boot/tests/contained-group.spec.ts +++ b/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