fix(cordis): preserve concurrent loader composition

This commit is contained in:
Tianyi Cui
2026-07-30 06:23:16 +08:00
parent 2fe205f01a
commit 0a297c39d3
6 changed files with 18 additions and 11 deletions

View File

@@ -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/bug-fix/2026-07-20-config-hot-reload-resilience.md
2026-07-20-config-hot-reload-resilience.md: f3c36f8055179870c19c9d1ce99c3533fe602aa6
2026-07-20-config-hot-reload-resilience.zh.md: 72ef2ebfa582dcc614198ed094c9b58ea1713460
2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee
2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35

View File

@@ -14,7 +14,7 @@ The vendored Cordis lifecycle and Loader plugins provide an awaited, compensatin
`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang.
Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation is sequential and restores earlier changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree.
Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation starts candidates concurrently, awaits every outcome, and restores changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree.
Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse.
@@ -26,7 +26,7 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met
**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition.
**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Sequential application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions.
**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions.
## Consequences
@@ -38,4 +38,4 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer.
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.

View File

@@ -14,7 +14,7 @@ vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的
`Fiber.update()` 返回其 `internal/update` waterfall瀑布式事件的结果。配置校验保持同步而默认 continuation 返回重启 promise。因此Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber并在 fiber 已结算为失败时 reject等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。
Loader 会先导入变化后的模块名,再 dispose资源释放活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect并恢复先前的插件或配置。组内对账按顺序进行,并会在拒绝前恢复此前已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。
Loader 会先导入变化后的模块名,再 dispose资源释放活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect并恢复先前的插件或配置。组内对账会并发启动各候选项,等待每项结果,并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
@@ -26,7 +26,7 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方
**每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。
**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。按顺序应用并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。
**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。
## Consequences
@@ -38,4 +38,4 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。
`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除ACPAgent Client Protocol`pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序

View File

@@ -184,6 +184,8 @@ describe('jsonrpc-agent keyless smoke', () => {
expect(exitCode, stderr).toBe(1)
expect(stdout).toBe('')
expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc')
expect(stderr).toContain('plugin tree failed to load')
expect(stderr).toContain('failed to apply loader entry jsonrpc (@deepseek-ai/dsh-jsonrpc)')
expect(stderr).toContain('sometimes')
}, 30_000)
})

2
vendor/README.md vendored
View File

@@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
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.
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 run sequentially, undo earlier changes and additions on 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/ui/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 start candidates concurrently, await every outcome, undo changes and additions on 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/ui/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. 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/ui/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 one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` 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/ui/app-boot/tests/config-reload.spec.ts`.

View File

@@ -68,7 +68,12 @@ export class EntryGroup {
const newMap = Object.fromEntries(config.map(options => [options.id, options]))
try {
for (const options of config) await this.create(options)
const outcomes = await Promise.allSettled(config.map(options => this.create(options)))
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)
}