cd /news/ai-agents/your-plugin-system-couldn-t-replace-… · home topics ai-agents article
[ARTICLE · art-134894] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Your plugin system couldn't replace plugins. So here's the transaction that you're missing.

A developer has released @moult/runtime 0.1.1, a plugin runtime that treats plugin replacement as a transactional protocol rather than a simple assignment, so a failed upgrade leaves the previous version active and usable. The project ships with 145 tests run under both Node and a DOM environment (290 runs) and a comparison harness showing it survives a failed upgrade with zero leaked resources, unlike a naive registry. The developer notes the safety costs roughly 0.16ms per replace in the benchmark environment, calling it environment-specific evidence rather than a performance promise.

by read5 min views1 publishedSep 20, 2026

What if an upgrade fails halfway through activating? The question would be: what is still running?

In most plugin systems, the answer is: none. And in registries, the common bug could be as follows:

// the old plugin is already gone before the new one exists
registry.set('storage', await next.setup());

If setup() throws, the old working value is destroyed, and the new one never arrives. Now, your host is missing a capability it had a millisecond ago. In a daemon, that's an outage, not an error.

This is the failure mode that matters most for systems which can't restart: long-running CLI daemons, editors, dev tools, and the new wave of AI-agent extension hosts, where shipping an upgrade should be routine, not a maintenance window.

Moult treats it like one. A plugin is a versioned capability provider with an owned resource scope, and replacing it runs a protocol, not an assignment:

If anything fails before the commit, the candidate scope is disposed, and the failure stays invisible: the previous generation remains active and usable. Like a crab, the system only sheds its shell once the new one is ready.

import { capability, createRuntime } from '@moult/runtime';

const storage = capability<{ get(k: string): string | undefined }>('storage', '1.0.0');
const runtime = createRuntime();

runtime.install({
  id: 'memory.storage', version: '1.0.0',
  provides: [{ capability: storage }],
  setup: (ctx) => {
    const map = new Map([['k', 'v1']]);
    ctx.provide(storage, { get: (k) => map.get(k) });
  },
});
await runtime.start('memory.storage');

// A broken replacement is rejected — and v1 keeps serving:
await runtime.replace({
  id: 'memory.storage', version: '2.0.0',
  provides: [{ capability: storage }],
  setup: () => { throw new Error('bug in the new version'); },
}).catch((e) => console.log(e.code)); // REPLACEMENT_FAILED

// Usable, not just active: a consumer installed after the failure
// still binds the old generation's value.
let seen: string | undefined;
runtime.install({
  id: 'reader', version: '1.0.0',
  requires: [{ capability: storage, range: '^1.0.0' }],
  setup: (ctx) => { seen = ctx.require(storage).get('k'); },
});
await runtime.start('reader');
console.log(seen); // 'v1' — the failed v2 never existed to readers

That is the entire guarantee, runnable: the rejection is a structured MoltError, and a consumer installed after the failure still reads v1's value usable, not just active.

There's a second half to "versioned capability provider" that the snippet hides: capabilities carry a semver version, consumers declare semver ranges, and each token declares whether it accepts exactly one provider or aggregates many (multiple: true). Resolution runs during preparation; a candidate whose requirements don't resolve fails before setup, so a version mismatch can never become visible either.

It's what's enforced. Nine replacement transaction tests cover failed setup, failed validation, disposal ordering, and resource cleanup, on top of property-based and stress suites: 145 tests, each run under both Node and a DOM environment — 290 runs, all green. The 15 invariants are written down in docs/guarantees.md, and the repo's comparison harness runs the same failed-upgrade scenario against a naive registry, cordis, and Moult, publishing the raw numbers in demo/comparison/RESULTS.md:

Runner Survives Leaked
naive registry no 102
cordis 4.0.0-rc.9 no 0
@moult/runtime 0.1.1 yes 0

Excerpt — the full table adds the blocked-stop diagnostic and the average-ms column.

(Their caveat, which I'll repeat: environment-specific evidence, not a performance promise.)

That safety isn't free: the whole benchmark scenario — install, one failed replace, and one hundred successful replaces — averages ~16ms against ~0.14ms for the naive registry (roughly ~0.16ms per replace in that environment, not ~16ms; scenario average, not per-call). You pay it per replace, never per capability read.

A note on the neighbours: HMR reloads modules, and Module Federation shares them. Both are code-delivery mechanisms — neither promises that an upgrade either fully applies or fully rolls back. Moult owns a different layer: the replacement transaction itself. And if you use Vite, @moult/vite routes module updates through the same replace path, so import/setup failures preserve the old generation and the bridge doesn't commit anything itself; the runtime's transaction does.

The honest limits, because a runtime that hides its limits can't be trusted with your uptime: Moult is not a sandbox — plugins are trusted code, and it governs lifecycle and capability visibility, not permissions. It's not a or bundler; there's no global registry. And v1 refuses to silently rebind dependents — replacing a provider with active dependents is rejected outright with a structured REPLACEMENT_FAILED error that names the dependent path, instead of quietly re-wiring them. Moult preserves service, not state: every generation gets a fresh scope, so in-memory handles don't migrate — React component state is explicitly not promised to survive replacement, and durable state belongs in a host-provided capability. The success path is just as strict: replaced fires before the old scope is disposed, old resources release in LIFO order, and if the old generation's disposal fails after commit, the failure is recorded and inspectable — the replacement stands; there is no rollback.

Around the runtime there's a small family: @moult/events (generation-scoped typed events), @moult/react (bindings for committed contributions), @moult/test (utilities for proving ownership, replacement, and leak behaviour), and @moult/vite (Vite HMR bindings that route module updates through the same transactional lifecycle).

npm install @moult/runtime  # Node 22 or newer

Repo: github.com/neryva-lab/moult — if you have an upgrade scenario your system can't survive, open an issue with the repro. I'd genuinely like to see it fail.

docs/guarantees.md

demo/comparison/RESULTS.md

@moult/runtime

── more in #ai-agents 4 stories · sorted by recency
── more on @moult 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-plugin-system-c…] indexed:0 read:5min 2026-09-20 ·