# Testing Multi Worker Dev

> How to run and test kody's multi-worker local dev (origin kody worker + kody-platform + kody-runtime + kody-jobs secondary configs), including known wrangler multi-config pitfalls (secondary worker env-name suffix, remote AI binding, .env secrets not propagated) and how to verify runtime-worker forwarding.

- Skill: `kentcdodds/testing-multi-worker-dev` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kentcdodds/testing-multi-worker-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kentcdodds/testing-multi-worker-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: kentcdodds (https://skillmd.com/u/kentcdodds)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kentcdodds/testing-multi-worker-dev

---


# Testing multi-worker local dev (kody + kody-platform + kody-runtime + kody-jobs)

## Basics

- Node 26 required: `export PATH="$HOME/.nvm/versions/node/v26.7.0/bin:$PATH"`.
- To start or reuse the local app, run `npm run dev:ensure`. It probes origin
  `/health` on 3742–3751, prints `App running at http://localhost:<port>` and
  exits 0 when a server is already up, waits for a stale kody/workerd leftover
  that is listening but not serving before replacing it, then starts
  `npm run dev` and waits until `/health` is actually ok. Do not inventory
  Cursor terminal files or curl 3742 as a substitute.
- Run interactive `npm run dev` in tmux when you need the CLI shortcuts; it
  starts the mock Cloudflare API worker, then Vite (`@pitlane/dev` +
  `@cloudflare/vite-plugin`) so origin SSR and the client hydrate in one workerd
  graph. Vite writes `packages/worker/wrangler-local-dev.generated.json` so
  origin `env` gets `WRANGLER_IS_LOCAL_DEV` and mock `CLOUDFLARE_API_*` vars
  (the Cloudflare Vite plugin does not map process env onto Worker bindings).
  Jobs and highlight join as Vite auxiliary workers in every serve, including
  `CLOUDFLARE_ENV=test`. Generated platform and runtime configs join only
  outside the test env. Default port 3742; the CLI picks the next free port if
  taken.
- Local dev uses `--env production` (CLOUDFLARE_ENV defaults to production in
  `wrangler-env.ts`).
- Migrate + seed login: `npm run migrate:local` then
  `node tools/seed-test-data.ts --local` → `kody@example.com` / `ilikecode`
  (admin) and `jane@example.com` / `ilikecode`.
- Healthchecks: origin `GET /health` → `{"ok":true,...}`; platform worker serves
  `GET /__platform/health`, runtime worker serves `GET /__runtime/health`, and
  jobs worker serves `GET /health`. Those sibling paths 404 on the origin port.

## Known wrangler multi-config pitfalls (handled by a generated dev config)

Wrangler applies `--local`, `--var`, and `.env`-derived secrets only to the
PRIMARY config, registers each worker under `<name>-<env>`, and treats a
secondary config's `ai` binding as always-remote (dev fails to boot with "Failed
to start the remote proxy session"; `"remote": false` is NOT enough).

Origin Vite serve uses the same pattern for the primary config:
`tools/local-origin-dev-config.ts` writes
`packages/worker/wrangler-local-dev.generated.json` and injects
`WRANGLER_IS_LOCAL_DEV`, mock `CLOUDFLARE_API_*`, and `APP_BASE_URL` into
`vars`. Worker secrets stay in `packages/worker/.env` / `.dev.vars`.
`wrangler-env.ts` still never passes the committed runtime or platform configs
to `wrangler dev` directly: `tools/local-runtime-dev-config.ts` and
`tools/local-platform-dev-config.ts` generate
`wrangler-local-dev.generated.json` next to each committed config (gitignored)
on each dev start. Those files pin the secondary registered names to
`kody-runtime` and `kody-platform`, drop each secondary `ai` binding, rewrite
Durable Object migrations for local replay (`tools/local-dev-migrations.ts`:
transfers become `new_sqlite_classes`, create-then-delete pairs such as
`PackageServiceInstance` are elided), and inject `APP_BASE_URL`,
`COOKIE_SECRET`, `SECRET_STORE_KEY`, and `WRANGLER_IS_LOCAL_DEV` from the dev
process env. Runtime still rewrites any remaining `script_name: "kody"` refs to
the primary's dev name (`kody-<env>`). If runtime-owned paths 503 with
`Worker "kody-runtime" not found` or a secondary worker 500s on missing vars,
inspect those generated files first.

If `npm run dev` or `npm run runtime:build` / startup-bundle dry-run fails with
`Cannot apply deleted_classes migration to non-existent class PackageServiceInstance`,
the config still has the production transfer+delete chain. Wrangler 4.131+
applies that local sqlite-class map on `deploy --dry-run` as well as `dev`.
`wrangler-env.ts` and `tools/check-worker-startup-bundles.ts` write
`wrangler-dry-run.generated.json` with `localizeMigrations` for those bundle
checks. `wrangler check startup` ignores `--config` and loads cwd
`wrangler.jsonc`, so `check-worker-startup-time.ts` snapshots a localized config
into a temp directory. Confirm `localizeMigrations` ran and that the generated
file's **top-level** `migrations` (not only `env.production.migrations`) has no
`PackageServiceInstance` delete. For `npm run dev`, confirm
`wrangler-local-dev.generated.json` instead.

## Jobs-worker Durable Object pitfall (boot failure)

With the jobs-worker extraction (ADR 0016), `npm run dev` may fail with
`service core:user:kody-production: Uncaught TypeError: Class extends value undefined is not a constructor or null`
(in miniflare's `createDurableObjectWrapper`) and "The Workers runtime failed to
start". Root cause: local dev (`--env production`) inherits the TOP-LEVEL
migrations chain in `packages/worker/wrangler.jsonc`, which still nets out
`JobManager` as a live DO class on the main script (v6 adds
JobManager+JobRunner, v9 only deletes JobRunner) — but `JobManager` is not
exported from `packages/worker/src/index.ts` (it lives in
`packages/jobs-worker`, which uses a `transferred_classes` migration from
`kody-production`). Miniflare replays the full chain fresh and tries to wrap the
missing class. The fix is a migration chain without the v6/v9 pair (matching
`preview`/`test`); if the error reappears, check that the top-level chain in
`packages/worker/wrangler.jsonc` does not create classes the main script does
not export.

## Verifying forwarding

- Unauthenticated `GET /@<user>/packages/<anything>` on the main port: 302 →
  `/login` proves the runtime lane handled it (broken binding gives 503
  instead).
- `POST /@<user>/api/package-invocations/x/y` without bearer → 401
  `{"ok":false,"error":{"code":"unauthorized"}}` from the runtime worker.
- Binding status line in dev output:
  `env.RUNTIME_WORKER (...) Worker local [connected]` vs `[not connected]`.
- Beware: logged-in package-app requests redirect to the real `https://kody.run`
  (production `PACKAGE_APP_BASE_URL`) — don't follow the handoff into prod.

