DeepSeek Harness plugin HMR reload: cache, timers, effects
The trap plugin authors fall into most is not writing bad code but "assuming hot reload took effect": apply() does rerun and the log shows a new activation record, yet the module is still old, the timer still carries last round's transaction marker, or the host crashes outright. These four failures each have their own signals; triage with the table first, then fix them one by one (#10774, #11121).
DeepSeek Harness: Triage table: symptoms and signals for four failures
In one sentence: no reaction to code changes is a watch-scope problem; a reaction with wrong behavior is a cache problem; nested-transaction errors are a marker problem; a host exit is an inactive-context problem.
| Type | Symptom | Key signal | Belongs to |
|---|---|---|---|
| 1. Module not refreshed | apply() reruns, logic unchanged | activation log but no new behavior | watch scope / ESM cache |
| 2. Poisoned timer | one nested-transaction error per cycle | cold start fine, always fails after hot reload | dsh-hmr defect |
| 3. Host crash | app exits outright | INACTIVE_EFFECT | plugin apply code |
| 4. Update refused | updating reports already-installed | appears only on rc.3+ | version behavior |
DSH plugin: Type 1: config hot reload is not module hot reload
hmr.config.root defaults to an empty array and only watches config files; a file:// plugin on another drive is not in the watched scope at all.
- Confirm the symptom: every touch of
cordis.patch.ymladds anotherapply()record, which means the plugin was rebuilt. - But the imported module is not re-evaluated — Node's ESM cache is keyed by resolved URL, and an unchanged URL keeps the old module.
- Check the watch scope: the base bundle sets
hmr.config.rootto[], so source watching is off; profile recomposition only covers the profile directory and linked package targets. - Fix it by configuring both
baseandrootin thehmrentry:
- id: hmr
disabled: false
config:
base: 'file:///F:/path/to/your/repo/'
root: ['plugin/dsh-live-workbench', 'server']
baseonly anchors relative paths;rootdecides what is actually watched, so changing onlybaseis the same as not enabling watching.- Make the plugin entry a static import of its handler so HMR has a dependency path to replace; a dynamic
import(...?v=timestamp)only refreshes the entry.
The community ran a two-stage check on the packaged desktop app: with root: ['.'], both entry changes and static dependency changes reloaded; narrowed to two directories, both still reloaded. The ?v= route has a ceiling — it re-evaluates only the entry, and sibling modules statically imported by the entry stay old.
DeepSeek Harness: Type 2: timers permanently carry the transaction marker
apply() executes inside an HMR transaction, and timers registered there capture the marker; the transaction ends milliseconds later, but the timer keeps it.
- Reproduce: the plugin uses
ctx.interval()inapply()for a 15-second heartbeat whose callback does a config write such assaveSelection(). - Cold start is fine and
hmr.executing.getStore()is undefined. - After hot reload,
getStore()is true inside every callback. - So
runExclusive()rejects right at the entry:HMR transactions cannot be nested. - The symptom is that every capability requiring a config write silently stops working, printing one error per cycle, until restart.
- The community's minimal reproduction (plain Node + AsyncLocalStorage) had 0 failures on cold start and 3/3 failures after hot reload.
The fix is not "clear the marker before entering the transaction". The community measured that this lets genuine nested calls through too: an inner task queues behind an outer one that is waiting on it, neither Promise can settle, and every later runExclusive on that instance never returns. The workable version gives each transaction an independent state object, sets active to false when it ends, and updates the other three read sites in the same file to getStore()?.active.
DSH Plugin: Type 3: the host dies with INACTIVE_EFFECT
The plugin's apply is async and never rechecks whether the fiber is still alive after the first await; when hot reload destroys the context before the second child plugin mounts, it crashes.
- Read the crash log:
CordisError: cannot create effect on inactive context, withcode: 'INACTIVE_EFFECT'. - Read the stack:
Fiber._reload→plugin()→assertActive. - Read the code shape:
applydoesawait ctx.plugin(workspaceDependencies, ...), then synchronous path work, then a secondawait ctx.plugin(officeSkills, ...). - Trigger: hot reload destroys or replaces that fiber while the first await is suspended, so the context is already inactive at the second mount.
- Fix one: if the two child plugins do not depend on each other, mount both in one synchronous turn.
- Fix two: recheck whether the fiber is still active after every
await, and return early when it is not.
This error is not a Cordis defect; assertActive behaves correctly. The caller crossed the invalidation point.
DeepSeek Harness: Type 4: the official manager refuses overwrite installs
In HMR mode, as soon as the profile dependency already contains a package with that name, the official manager refuses outright — pnpm never even runs.
- Read the error:
official plugin manager update failed: refused to execute (already-installed). - Match the version: rc.1 and rc.2 support overwrite updates; rc.3, rc.4, and rc.5 do not, as the community measured version by version.
- Recovery path: uninstall the plugin → official restart → reinstall.
- That is currently the only stable path, and the upgrade guide already documents removeBundle followed by installBundle.
- To bypass it you can use DSH's bundled pnpm with
add <package>@<version> --save-exact, but that is manual intervention, not a UI capability. - The root fix is an atomic
updateBundle: install into a temp directory and swap, leaving the profile untouched on failure.
DSH plugin: Cautions
Prove a reload with behavior, not with log lines.
- Do not use "an activation log appeared" as proof hot reload worked. It proves only that
apply()reran. - Do not configure only
base. With an emptyroot, source watching is still off. - Do not register timers that write config inside
apply(). They always break after hot reload unless core fixes it. - Do not use ctx directly after an await. Recheck liveness after every asynchronous step.
- Cold-start once after changing a plugin. Defects in the hot-reload path easily mask real logic problems.
If none of the four match, first confirm whether your plugin is registered through a profile patch as a file:// path — that is the most common starting point.See DeepSeek Harness plugin load: already registered, pending for the related case.
FAQ
Config hot reload and module hot reload are two separate paths in DeepSeek Harness. The base bundle sets hmr.config.root to [], so it watches only config files and not source; a file:// plugin on another drive is outside the watched root anyway. Configure both base and root in the hmr entry.
The timer keeps failing because DeepSeek Harness runs apply() inside an HMR transaction, and timers registered there get the transaction marker pinned by AsyncLocalStorage; the transaction ends but the marker stays. Any runExclusive() in the callback is rejected by its own nesting check until the process restarts.
No, clearing the outer marker is not a good fix in DeepSeek Harness, because the community measured that it lets genuine nested calls through too: an inner task queues behind an outer one that is waiting on it, the queue deadlocks permanently. A workable version gives each transaction its own state object with active, set to false when it ends.
No, it is not a Cordis bug: in DeepSeek Harness apply is an async function and does not recheck whether the fiber is still alive after the first await ctx.plugin(...), so when hot reload destroys the context and a second ctx.plugin runs, it throws. Fix it by mounting both child plugins synchronously, or rechecking after each await.
In HMR mode the official DeepSeek Harness plugin manager does not allow overwriting an installed plugin. The recovery path is uninstall → official restart → reinstall. The community measured that rc.1 and rc.2 allow overwrite updates, while rc.3 through rc.5 always show this error.
Related Terms
- HMR
- HMR is the DeepSeek Harness hot module replacement mechanism that rebuilds plugins when config files or source change. Config hot reload and module hot reload are independent paths, and only the former is on by default.— https://github.com/deepseek-ai/deepseek-harness/discussions/10774
- runExclusive
- runExclusive is the transactional method in dsh-hmr that serializes operations, marking a transaction in progress with AsyncLocalStorage. It checks for an existing marker on entry and rejects a hit as a nested transaction.— https://github.com/deepseek-ai/deepseek-harness/discussions/11121
- INACTIVE_EFFECT
- INACTIVE_EFFECT is the error Cordis throws when creating an effect on an already disposed context. It usually means an asynchronous operation crossed the point where the fiber was destroyed.— https://github.com/deepseek-ai/deepseek-harness/discussions/11306
- already-installed
- already-installed is the error the official plugin manager returns when it refuses to overwrite an installed plugin in HMR mode. It reflects a version behavior difference rather than plugin corruption.— https://github.com/deepseek-ai/deepseek-harness/discussions/11039
Sources
- #10774 — Plugin patch hot-reload re-runs apply() but keeps already-imported ESM modules cached· deepseek-ai (GitHub Discussions)
- #11121 — after hot reload, apply() timers permanently carry executing=true· deepseek-ai (GitHub Discussions)
- #11306 — Desktop host dies with CordisError INACTIVE_EFFECT during plugin hot-reload· deepseek-ai (GitHub Discussions)
- #11039 — the plugin manager refuses in-place upgrades of an installed plugin in HMR mode· deepseek-ai (GitHub Discussions)