Deploying executor-cloud
executor-cloud deploys with an upload then staged-promotion flow, not an
immediate 100% cutover. This exists because every full deploy restarts all
Durable Objects, and the MCP session DOs (McpSessionDOSqlite) hold live
streaming sessions: a cutover mid-call drops in-flight MCP tool results. Staging
the promotion bounds that blast radius and keeps a mere merge from disrupting
anything.
The mechanics below were validated empirically on a throwaway staging worker that mounted the real session DO and patched transport with stub auth; the "what happens" claims are measured, not assumed. The findings are summarized at the end.
The flow
-
Merge to main runs
.github/workflows/deploy.yml(pushtrigger only):migrateapplies database migrations.upload-cloudrunswrangler versions upload. This creates a new Worker version but routes no traffic to it. No DOs restart; live MCP sessions keep running. This is safe to do on every merge.deploy-marketingdeploys the marketing worker (unchanged behaviour).- Note the uploaded version id from the
upload-cloudjob log (or runwrangler versions list): you promote it by id below.
-
Promotion is a separate, deliberate, DECOUPLED step. Run the Deploy workflow via workflow_dispatch with two inputs:
promote_percent(1-100): the traffic percentage for this step.promote_version: the version id to promote (required). Use the literallatestonly for "promote what I just merged"; for advancing an existing canary always pass the same version id you canaried, so you widen that reviewed version rather than something newer.
A promote-only dispatch runs only the
promote-cloudjob: it does not re-runmigrateorupload-cloud, so it never builds a new version. The job promotespromote_versiontopromote_percentof traffic (scripts/promote-cloud.ts, which callswrangler versions deploy).Canary walk for one version
V:- dispatch
promote_percent=10,promote_version=V(10% onV); - watch telemetry;
- dispatch
promote_percent=50,promote_version=V(widen the sameV); - dispatch
promote_percent=100,promote_version=V(full cutover).
Merges that land after you start a canary upload their own versions but do not get promoted until you dispatch with their id: advancing
Vnever silently ships unreviewed code.
Promoting from the CLI (manual, out of band)
From apps/cloud, against the generated build config. --version is required;
it is either an explicit version id or the literal latest:
bun run build
wrangler versions list -c dist/server/wrangler.json # find the version id
# Canary: version <id> at 10%, current-live at 90%.
bun run scripts/promote-cloud.ts --percent 10 --version <id>
# Advance (same version id):
bun run scripts/promote-cloud.ts --percent 50 --version <id>
# Full cutover (same version id):
bun run scripts/promote-cloud.ts --percent 100 --version <id>
# Or, right after a merge, promote the most recent upload explicitly:
bun run scripts/promote-cloud.ts --percent 10 --version latest
The script promotes exactly the version you name (latest resolves to the
highest version number) and holds the remainder of a partial promotion on the
current-live version. It refuses to run unless the resolved wrangler config
targets the executor-cloud worker (set PROMOTE_CLOUD_ALLOW_NAME to override
for a non-production worker).
Session version-affinity (zone ruleset, MUST be configured manually)
During a gradual rollout the stateless front worker and the session DO can run different versions. DOs are natively version-pinned (a DO keeps its assigned version for the deployment), but the front worker is not: without affinity, consecutive requests for one MCP session hash to different worker versions, so a request can hit a worker version that differs from its DO's version (measured: a v2 front worker talking to a v3 DO).
Cloudflare routes a request to a specific version when the
Cloudflare-Workers-Version-Key header is set: the same key value always maps
to the same version for the life of a gradual deployment. We set that key from
the MCP session id so every request for one session sticks to one worker
version.
This is a zone-level request-header-transform rule on executor.sh and
cannot be set from the repo. Create it in the Cloudflare dashboard (Rules ->
Transform Rules -> Modify Request Header, or the Rulesets API, phase
http_request_late_transform) that copies the mcp-session-id request header
into Cloudflare-Workers-Version-Key. The exact rule:
# When: /mcp requests that carry a session id.
(http.request.uri.path wildcard "/mcp*" and len(http.request.headers["mcp-session-id"]) > 0)
# Action: Set static / dynamic request header
# Header name: Cloudflare-Workers-Version-Key
# Value (dynamic expression): http.request.headers["mcp-session-id"][0]
Requests without the header (e.g. the initial initialize that mints the
session) fall back to normal weighted routing, which is correct: there is no
session to pin yet. Once the client echoes mcp-session-id, every subsequent
request for that session is version-pinned to the worker version its DO landed
on.
Validated on the staging worker by sending Cloudflare-Workers-Version-Key
directly (12 requests per key across a 4-key set during a two-version gradual
deployment): each distinct key deterministically mapped to exactly one version
across all its requests, while unkeyed requests split across versions per the
weighted split. In production the header-transform rule sets the key from the
session id so clients never send it themselves.
Durable Object migrations deploy separately
DO class migrations (the migrations array in wrangler.jsonc: new_classes,
new_sqlite_classes, renames, deletes) take effect when a version carrying them
is promoted, not merely uploaded, and Cloudflare validates a DO delete
against the live binding. Policy:
- Never combine a DO class change with a risky app change in one version you intend to canary slowly: a DO migration wants a clean promotion.
- To delete an orphaned DO class, promote a version that still exports it as a
stub first (nothing binds it), then delete it in a later version. This is
already how
McpSessionDO(KV) was retired in favour of the SQLite class.
Rollback
Roll back by promoting the previous-good version to 100%:
wrangler versions list -c dist/server/wrangler.json # find the good version id
wrangler versions deploy -c dist/server/wrangler.json <good-version-id>@100% --yes
Constraints:
- Rollback takes effect fast and the session survives. Measured: promoting
the previous version back to 100% took effect within seconds (all subsequent
requests served by the rolled-back version), and a session that existed before
the rollback still worked afterwards, its
tools/listreturned 200 and its DO storage was intact. As with any promotion, a call in flight at the instant of the rollback is lost (the DO whose pinned version changes restarts), but the session id and its persisted state carry across. - Migrations can block a rollback. A version cannot be promoted if its DO migration state is behind the account's applied migrations (you cannot "un-create" a DO class by rolling back). If a release included a DO migration, rolling back to a pre-migration version may be refused; roll forward with a fix instead.
What the staged flow does and does not fix
Measured on the staging worker:
- Fixed, merges no longer disrupt anything.
wrangler versions uploaddoes not restart DOs; a session opened before an upload runs straight through it. Measured: an upload fired mid-call (mid a ~30s in-flight tool call) caused zero disruption, the call's result marker was delivered normally. Most CI runs now upload only. - Fixed, staged blast radius. A gradual promotion only restarts the DOs whose sessions land on the newly promoted version at each step, instead of all DOs at once.
- Fixed, no mid-session worker/DO version skew once the affinity ruleset is in place: the front worker matches the session's DO version for the rollout window.
- Not fixed yet, the promotion itself still restarts DOs whose pinned version
changes. Promoting a new version to traffic moves those DOs' version and
restarts them, so an in-flight call at the moment of promotion is still lost
(same shape as the old cutover, but now scoped to the promoted slice, not
everyone). Measured: a
versions deploy new@100fired mid-call ended the in-flight stream with no result. Eliminating this needs durable-pause work on the DO and is out of scope here. - The session outlives a promotion even though the in-flight call does not. Measured across the version swap: the session id stayed valid, the persisted event store survived the swap intact, and a reconnect GET replayed the prior events across the version boundary (older-version events replayed correctly on the newer version). So a client that reconnects recovers its stream, it just has to re-issue the one call that was in flight at the instant of promotion. This is why promotion is the only disruptive moment: uploads are free, and even a promotion loses only the single in-flight call, not the session or its history.
Re-validating the mechanics
The measurements above came from a throwaway staging worker (a cut-down worker
mounting the real McpAgentSessionDOBase + the patched agents transport with
stub auth and a trivial execute tool, deployed to a workers.dev subdomain,
never routed). To re-validate after a Cloudflare behavior change, rebuild the
same shape: exercise affinity with Cloudflare-Workers-Version-Key across a
two-version gradual deployment, fire an upload and a promotion mid-call, and
roll back mid-session. Keep any such worker on workers.dev only and delete it
when done.