Skip to content

Replacing DynamicWorkerExecutor

Hosts on @cloudflare/codemode call DynamicWorkerExecutor. That executor needs the Workers platform and the WorkerLoader binding it starts a Dynamic Worker through. @supolka/cloudflare-codemode-executor implements the same dialect over an executor that runs anywhere: in your own process, or in a daemon beside it. Every section below assumes a host that already speaks the dialect. Hosts starting fresh write against the native contract in the executor protocol instead, and the NestJS example shows that shape.

CodemodeExecutor implements the dialect over any native executor, so only the construction changes:

// on Workers
import { DynamicWorkerExecutor } from '@cloudflare/codemode'
const executor = new DynamicWorkerExecutor({ loader: env.LOADER })
// anywhere Node runs
import { CodemodeExecutor } from '@supolka/cloudflare-codemode-executor'
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new CodemodeExecutor(new IsolateExecutor())

env.LOADER is the only field that constructor requires, and CodemodeExecutor takes a native executor in its place. IsolateExecutor keeps the pool in your own process, and the isolates in the children it supervises, with no network between them. To move the whole pool into a daemon instead, pass a connection:

import { CodemodeExecutor, connect } from '@supolka/cloudflare-codemode-executor'
const executor = new CodemodeExecutor(await connect('ws://executor:8080'))

connect builds the socket from the runtime’s own WebSocket. A runtime that has none, or a handshake that needs headers, passes an implementation as websocket instead. Everything else the host already wrote stays: the provider objects with their functions, the execute(code, providers, options) call, and the {result, error, logs} it returns. So do the log prefixes, the tool name sanitization and its collision text, and the timeout sentence.

One extension exists beyond the dialect. DynamicWorkerExecutor takes the module map, the timeout and the outbound fetcher in its constructor, and its execute options have no field for any of the three. So a host that varies one of them builds a fresh executor per call, and here that would mean a fresh pool per call. ExtendedExecuteOptions puts the same three on the call instead:

await executor.execute(
`import { total } from './invoices.js'
async () => total(await billing.invoices('ada'))`,
[{ name: 'billing', fns: { invoices: async (account) => [{ id: 'i1', account }] } }],
{
modules: { './invoices.js': 'export const total = (rows) => rows.length' },
timeout: 5_000,
outbound: null,
},
)
// { result: 1, logs: [] }

modules rides on the call, so each execution gets its own graph and the pool under it stays warm.

DynamicWorkerExecutor blocks outbound traffic unless the host opts in. Its globalOutbound defaults to null, and its own types call that default runtime-enforced. fetch and connect throw inside the sandbox, and a host that wants a controlled path passes a Fetcher. But this executor’s outbound reads the other way round. Omitting it means native, so the executor performs whatever request the program asks for, from the machine the executor runs on, with the network position that machine has. null is the value that closes it.

One field means opposite things on the two sides, and a migrating host is most likely to leave exactly that one alone:

@cloudflare/codemode this executor
Nothing passed blocked the executor performs the request
null blocked blocked
A function or Fetcher routed through it routed through it

Swap two lines and leave the rest alone, and your model-written code moves from no network at all to full egress from the machine the executor runs on. Keep the behaviour it had by saying so:

const executor = new CodemodeExecutor(new IsolateExecutor({ outbound: null }))

outbound: null restores what globalOutbound gave by default. Opening a controlled path instead means passing a function, and the network guide says what to put in it. That policy runs in your own process, so the decision stays with you.

Inside the isolate the runtime is written from the standards, not borrowed, so the surface is close to what a Worker gives and not identical to it. Migrating hosts meet these three differences in practice:

What a Worker gives What this runtime does
nodejs_compat for a node: import No Node exists in the sandbox and no flag turns one on. A bundle that imports node:async_hooks gets whatever your build step put in the module map under that name, and no other name resolves.
WebAssembly, and a buffer that grows after it was created WebAssembly is gone from the global, and a buffer cannot grow past the length it was created with, shared or not. Each of those allocates memory the isolate’s limit does not meter.
CompressionStream, DecompressionStream and caches The names exist as declared absences, and the first touch reports a text saying why and what remains possible.

A bundle built without that flag already aliases its node: specifiers at build time, and the same alias becomes a module-map entry here. Every isolate is disposed when the run settles, as it is on Workers, and retainHandles is the one negotiated exception. No field for it exists in the dialect’s execute options, and the persistence contract specifies it on the native contract. Runtime deviations lists every place this runtime differs from the standard that governs an interface.

The dialect hands the executor a provider’s functions, and those functions stay in your process. Only data crosses the boundary, in both directions. Whatever proxy source a provider supplied, the adapter refuses a tool that provider never listed with Tool "<tool>" not found. A provider nobody declared is not a name in the sandbox at all. But one shape stays open on purpose. A connector’s descriptor declares an open list, and its calls reach callTool under any name. There, and for any host writing against the native contract, your own function decides what a call gets, and not the list the model was shown. Providers and the sandbox scope states the rule.

Instrumentation the host wrapped around the executed code also stays the host’s, and it moves without an executor change. The host wraps the model’s arrow in one of its own: the wrapper captures the console, counts what it wants to count, and flushes at the end through a provider it declares for itself, while the dispatchers are still live. This repository’s own host suite runs that whole shape against this executor on every run of pnpm verify. It covers the console capture, a record of the hosts the run fetched, and an end-of-run flush.

Your platform used to bound the run. Here the executor does it, in four places. V8 enforces the memory limit on the isolate’s heap, and the runtime closes the allocation paths the engine keeps outside that meter. Time is measured on the host, so a program that never yields still stops at its bound. When the queue is full, execute rejects with a typed retryable fault instead of growing a backlog. And what a run sends outward is bounded where it is built. The bounds guide covers all four, and the threat model says what each one enforces and what stands behind it. Every run of pnpm verify measures that they hold, under the corpus of the benchmark.

Failures the executor declares are messages written for the model to read and act on, not codes for a log. A run that filled its heap is told to work in smaller pieces; a run that met a closed network is told to ask through a tool call. The failure grammar declares each one. But the timeout is the exception on this path. The native text names the bound, and the adapter renders it back down to the sentence the dialect pins, so a migrating host reads what it read before.

One case set replays through the real DynamicWorkerExecutor in a real workerd and through this executor, and the differential suite diffs the two outcomes:

Terminal window
pnpm nx test executor-testbed

It runs against every line the peer range admits: the installed release, and a pinned build of each older line still inside the range. Run your own corpus through both executors the same way, and compare the outcomes. The adapter chapter says how that suite’s case model is built for exactly that.

Swapping the executor keeps the dialect and changes what runs under it, so what your host already wrote goes on working. Agent loop with the AI SDK assembles the whole host around it. There the SDK declares the tools, @cloudflare/codemode collapses them into one code tool, and this executor runs what the model writes. The executor protocol is that executor’s own contract. Call it directly to ask for what the dialect has no field for, a retained execution first among them. That contract also states the wire form, so a host that would rather implement it than call it starts there.