|
|
@@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
-The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
|
|
|
+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
|
|
|
|
|
|
-Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
|
|
|
+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](../../../../vendor/README.md).
|
|
|
|
|
|
-- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
|
|
|
-- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
|
|
|
-- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
|
|
|
+`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.
|
|
|
|
|
|
-Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
|
|
|
+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
|
|
|
|
|
|
-**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
|
|
|
+**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.
|
|
|
|
|
|
-**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
|
|
|
+**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.
|
|
|
|
|
|
-**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
|
|
|
+**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 bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
|
|
|
-- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
|
|
|
-- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
|
|
|
-- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
|
|
|
+- 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/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
|
|
|
+`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.
|