Async Reloads, Epochs and the Race You Probably Missed

Agents
Systems
Distributed Systems
Why asynchronous reloads create coherence races and how dependency epochs contain them.
Author

Chirag Sehra

Published

August 17, 2026

await Changes the Problem

Suppose an MCP plugin starts like this:

async def start_mcp(ctx):
    transport = ctx.services["mcp_transport"]

    connection = await transport.connect()

    tools = await connection.list_tools()

    ctx.services["tools"].register_many(tools)

Look at the two await points.

At the beginning:

mcp_transport = server A

During:

await transport.connect()

the configuration changes.

Now:

mcp_transport = server B

But the coroutine still holds:

transport = server_A

It eventually registers tools discovered from A into a runtime whose current provider is B.

We have produced:

current configuration: server B

installed tools:       server A

The component may appear active, but it is active against the wrong dependency world.


This Is a Coherence Problem

Our simple state machine saw only:

before: requirements satisfied
after:  requirements satisfied

So:

ACTIVE → ACTIVE

looks fine.

But identity changed.

The paper introduces an epoch associated with the concrete resolved dependency configuration.(Cordiverse 2026)

For dependency specification d, the intuition is:

\epsilon_d(\sigma) = \langle \sigma(k) \mid k \in d \rangle

Think of it as a dependency fingerprint.


Example: Dependency Epochs

Our component requires:

requires = {
    "llm",
    "tools",
}

Suppose the runtime resolves:

llm   → DeepSeekProvider instance #17
tools → ToolRegistry instance #5

Conceptually, an epoch could identify:

(
    id(llm_provider_17),
    id(tool_registry_5),
)

Call that:

Epoch 41

The component starts loading against Epoch 41.

Now the LLM changes:

llm → DeepSeekProvider instance #18

The dependency set remains satisfied.

But the epoch becomes:

Epoch 42

The runtime now knows:

The component’s target configuration changed even though all dependency keys remain present.


Add Epochs to Our Python Runtime

class Component:
    def compute_epoch(self, runtime):
        if not self.satisfied(runtime):
            return None

        return tuple(
            id(runtime.services[name])
            for name in sorted(self.requires)
        )

A Boolean tells us:

ready / not ready

An epoch tells us:

ready against exactly this dependency resolution

That is a stronger statement.


Why a Boolean Is Not Enough

These all collapse to True:

{llm=A, tools=X}
{llm=B, tools=X}
{llm=C, tools=Y}

An epoch distinguishes them:

{llm=A, tools=X} → epoch 1
{llm=B, tools=X} → epoch 2
{llm=C, tools=Y} → epoch 3

A component is not merely:

ACTIVE

It is:

ACTIVE against configuration N

That distinction is what lets the runtime reason about stale bindings.

DeepSeek Harness’s current client HMR documentation explicitly notes that dependent reloads are driven through Cordis using service-provider identities in a fiber’s activation epoch.(DeepSeek Harness contributors 2026)


Example: The MCP Race

sequenceDiagram
  participant Old as Old transition
  participant Runtime
  participant New as New configuration
  Old->>Runtime: start against Epoch 7 / MCP_A
  New->>Runtime: MCP_A removed, MCP_B installed
  Runtime-->>New: publish Epoch 8
  Old->>Runtime: resume after await
  Runtime-->>Old: epoch mismatch: abort and unwind
  Runtime->>New: restart against Epoch 8 / MCP_B

Start:

Epoch 7

transport = MCP_A
tools     = registry_1

Component begins:

connection = await MCP_A.connect()

While waiting:

MCP_A removed
MCP_B installed

Now:

Epoch 8

transport = MCP_B
tools     = registry_1

When the old transition resumes, it should not blindly publish work derived from Epoch 7 into Epoch 8.

A simplified policy is:

if current_epoch != starting_epoch:
    abort_old_transition()

Then:

abort old transition
        │
        ▼
undo partial managed effects
        │
        ▼
reload against latest epoch

Effects Need Interruption Boundaries Too

Suppose startup performs:

async def load():
    register_prompt()
    await connect()
    register_tools()
    await warm_cache()
    register_handlers()

The epoch changes during:

await warm_cache()

What should be undone?

Only operations that have actually completed:

register_prompt ✓
connect         ✓
register_tools  ✓

warm_cache      incomplete
handlers        not started

Recovery should undo completed managed effects in reverse order.

This is why lifecycle machinery often needs incremental transition boundaries rather than one monolithic load() callback.


A More Realistic Python Sketch

class Transition:
    def __init__(self):
        self.undo = []

    async def step(self, operation, epoch_ok):
        if not epoch_ok():
            raise EpochChanged()

        undo = await operation()
        self.undo.append(undo)

        if not epoch_ok():
            raise EpochChanged()

    async def rollback(self):
        while self.undo:
            undo = self.undo.pop()
            result = undo()

            if hasattr(result, "__await__"):
                await result

Plugin startup:

async def start_mcp(runtime, component, epoch):
    tx = Transition()

    def valid():
        return component.compute_epoch(runtime) == epoch

    try:
        await tx.step(connect_transport, valid)
        await tx.step(register_tools, valid)
        await tx.step(register_prompts, valid)

    except EpochChanged:
        await tx.rollback()
        raise

Again, this is not Cordis.

It exposes the race that the more complete lifecycle model must handle.


Inertia: Do Not Race Reload Against Unload

Consider rapidly changing configuration:

t0  provider A exists
t1  provider disappears
t2  provider B appears
t3  provider B disappears
t4  provider C appears

while startup itself takes two seconds.

A naïve system might concurrently spawn:

reload A
unload
reload B
unload
reload C

Now lifecycle transitions race with one another.

A stronger design runs state migrations one at a time. If a newer target arrives during a transition, the system remembers it and handles it after the current transition reaches a safe boundary.

Conceptually:

target changes
     │
     ▼
RELOAD starts
     │
     │ target changes again
     │
     ▼
RELOAD finishes
     │
     ▼
Is target still current?
     │
   no
     │
     ▼
reconcile toward latest target

Not:

reload ──────┐
unload ──────┼── uncontrolled race
reload ──────┘

Relatable Example: Device Lifecycle Transitions

Kernel device lifecycle operations are not instantaneous. Probe, suspend, resume, bind and unbind can have ordering and concurrency constraints.(The Linux kernel developers n.d.)

The general OS lesson is:

A lifecycle transition has duration and needs coordination.

You cannot always model:

OLD → NEW

as a single assignment.

Real systems look like:

OLD
 │
 ▼
TRANSITIONING
 │
 ▼
NEW

Cordis brings similar concerns into component composition.


Relatable Example: TOCTOU

There is also a resemblance to a time-of-check/time-of-use problem.

A program observes:

resource = X

then time passes.

Later it uses X.

The world may have changed between observation and use.

Our component does:

llm = resolve("llm")
await initialise()
use(llm)

The lookup was correct when performed.

That does not mean the dependency configuration is still current when startup commits.

An epoch provides a versioned answer to:

I started against configuration N.

Am I still completing against configuration N?

The analogy is useful, but an epoch in Cordis is a lifecycle and dependency tool, not a general-purpose check-then-use tool.


HMR Is Less Magical Once We See the Lifecycle

Once we have:

revertible managed effects
+
dependency re-resolution
+
epoch-aware component lifetimes

hot module replacement becomes easier to reason about.

Conceptually:

Plugin v1
   │
   ▼
remove v1 contribution
   │
   ▼
resolve current dependencies
   │
   ▼
Plugin v2

rather than:

mutate a live module
and hope every reference follows

This architecture still does not imply automatic recovery from every failed hot update.

DeepSeek Harness’s current client HMR README explicitly states a known limitation: there is no failure rollback; if a reload fails, the entry remains FAILED and the previous bundle is not automatically restored.(DeepSeek Harness contributors 2026)

That limitation becomes very important in the final security discussion.


Previous: Reactive Coeffects: Dependencies That Control Component Lifetime · Next: From Clever Plugin Runtime to Programming Paradigm


References

Cordiverse. 2026. A Programming Paradigm for Spatiotemporal Composability. https://github.com/cordiverse/paper.
DeepSeek Harness contributors. 2026. @Deepseek-Ai/Dsh-Client-Hmr. https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/hmr/README.md.
The Linux kernel developers. n.d. Device Links. https://docs.kernel.org/driver-api/device_link.html.