A config hot-reload must not kill or degrade a live app
配置热重载不得杀死或降级正在运行的应用
An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, incl
English
Problem
An invalid cordis.yml edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup.
Decision
The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in vendor/README.md.
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 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.
HMR contains live refresh rejection. Its registerConfig(filename, refresh) method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to Error, logged, and broadcast through the parallel hmr/config-update-failed(filename, error) event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed.
Alternatives considered
Contain failures inside Include.refresh(). Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation.
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. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions.
Consequences
- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection.
- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise.
- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists.
- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber.
- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest.
Testing
packages/boot/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/boot/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.
中文
Problem
无效的 cordis.yml 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,即使该文件或其父目录在启动后才创建也不例外。
Decision
vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 vendor/README.md 中记录为本地修改第 6、8、9 条。
Fiber.update() 返回其 internal/update waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。EntryTree.await() 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。
Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账会并发启动各候选项,等待每项结果,并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 AggregateError,而不会被误称为树已保留。
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,refresh() 会向调用方 reject。初始加载仍会明确报错;只有文件不存在时才可以使用 initial。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
HMR 收容实时刷新 rejection。其 registerConfig(filename, refresh) 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer;该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 Error、记入日志,并通过并行事件 hmr/config-update-failed(filename, error) 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。
Alternatives considered
在 Include.refresh() 内收容失败。 已否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交;HMR 负责收容和观察。
每次编辑配置都重启进程。 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。
承诺不可见的原子替换。 已否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。
Consequences
- 实时刷新失败会在内部 reject;补偿成功时会保留或恢复上一份完好的树,并广播一次类型化失败,而不会成为未处理的 rejection。
- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复。
- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在。
- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放。
- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest(元数据清单)中。
Testing
packages/boot/app-boot/tests/config-reload.spec.ts 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。packages/boot/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 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 pty-tools 快照会防止并发组合改变同优先级提示词段的顺序。