Decision records
ADR 0002 — Dedicated HTTP Worker + RemoteWorkerRuntime
- Status: Accepted (Step 2 of 5 — Cloud Run compute provider)
- Date: 2026-07-07
- Builds on: ADR 0001
Context
ADR 0001 introduced the WorkerRuntime SPI and an in-process LocalWorkerRuntime.
To run executions off-box we need (a) a network transport implementing the SPI
and (b) a worker process to receive dispatches. Per the decision recorded with
the user, the worker exposes a new, dedicated endpoint rather than reusing the
A2A agent-to-agent protocol — A2A stays a peer protocol; execution transport is a
separate concern.
Decision
Two packages:
-
@loom/worker-server—createWorkerServer(worker: WorkerRuntime, options), a statelessnode:httpendpoint (borrowing A2AServer’sparseBody/jsonmechanics, dropping its in-memory handleMap):POST /executewith{ "executionPackage": {...} }→ per request:allocate → execute → releaseon the injected worker → returns theExecutionResultJSON. No execution state is retained between requests; the worker rebuilds from the shared event + snapshot stores the injected runtime points at.GET /health→{ healthy: true }. The server is a thin HTTP adapter over aWorkerRuntime; the “code bundle” (flows + effects) lives in theWorkflowRuntimeinside the injectedLocalWorkerRuntime. This is the container entrypoint for Step 3.
-
@loom/worker-runtime-remote—RemoteWorkerRuntime(baseUrl), the HTTP client impl of the SPI.allocate/releaseare trivial (no client-side state);executePOSTs the package to{baseUrl}/executeand parses the result. Fetch is injectable (fetchImpl) and typed structurally so the package needs no DOM lib.
Nothing else changes: the same LocalDispatcher now wraps a RemoteWorkerRuntime
instead of a LocalWorkerRuntime, and callers (the orchestrator/bus) are
unchanged.
Consequences
- Moving execution to a remote process is a one-line swap of the injected worker.
- Statelessness is proven, not assumed: a suspend followed by a resume are two
independent
POST /executecalls that succeed purely by rebuilding from shared SQLite — the worker keeps nothing in memory across the gate. ExecutionResultdoubles as the wire contract; it round-trips through JSON (folded state included), so remote and in-process results are byte-comparable (modulo ids/timestamps).- Cloud specifics (instance lifecycle, placement, cold start) are deliberately
absent — they belong to
CloudRunWorkerRuntime(Step 3), which isRemoteWorkerRuntime+ instance management behind the same SPI.
Verification
- Unit:
RemoteWorkerRuntime(POST shape + trailing-slash normalization; non-2xx → throws with status + body) via injected fetch. - Integration (
worker-server): local dispatch → HTTP worker → shared SQLite → replay. Covers/health, straight-through completion, suspend-then-resume across two POSTs, and folded-state parity with in-processLocalWorkerRuntime. - Full repo: build + typecheck clean, 62/62 test tasks green, biome clean.
Amendment (2026-09-28, L20). POST /execute answers a refusal because another holder owns
the execution with 409 { code: "superseded" } instead of a 500, and RemoteWorkerRuntime
maps it to ExecutionSupersededError, so a retrying dispatcher can decline to redispatch it.
See ADR 0004’s amendment.
Source: docs/adr/0002-http-worker-and-remote-worker-runtime.md in the loom repository.