Parallel Worktrees (worktrunk + mise)
Run multiple GAIA branches side by side. Each worktree gets its own copy of the
repo, its own gitignored env files, and a deterministic set of dev ports so
mise dev in one worktree never collides with another. Shared Docker infra
(Mongo/Postgres/Redis/RabbitMQ/Chroma) is used by all worktrees at once.
wt is pinned in mise.toml [tools] (ubi:max-sixty/worktrunk), so every clone
has it. Run it via the mise shim (wt ...) or mise exec -- wt ....
One-time setup (per machine)
wt config shell install # enables `wt switch` to cd for you; restart the shell after
wt config state default-branch set master # GAIA merges everything into `master`
GAIA's base branch is master — the remote's origin/HEAD points at master,
so worktrunk detects it correctly on fresh clones. New feature branches are cut from
master — fetch it fresh before branching, and merge master back in to keep the
PR mergeable.
Create a worktree and start working
wt switch -c feat/my-thing # creates the worktree + branch, runs pre-start hooks
mise dev # API + web on THIS worktree's ports (see below)
mise seed # mint + seed the dev bypass user against this API
wt switch -c fires the .config/wt.toml pre-start hooks, which run once at
creation and block until done:
wt step copy-ignored— copies the gitignored secret files listed in.worktreeinclude(apps/api/.env,apps/web/.env.local,apps/bots/.env) from the main worktree.mise run wt:env— allocates this worktree's ports →.env.worktree.pnpm install+nx run api:sync— concurrently; both are fast (pnpm shared store + uv cache), and neither copiesnode_modules/.venv.
When the command returns, mise dev works immediately with isolated ports.
Ports
OAuth / integration work needs the default 8000 + 3000. Redirect URIs are
registered with the provider (WorkOS, Composio, Google, …), and only
localhost:8000 / localhost:3000 are registered — a worktree on 8140/3140
gets its callback rejected, so connecting an integration cannot complete. Any
task that connects an account, tests a connect card, or touches an OAuth
callback runs on the default ports: rm .env.worktree in that worktree (mise
falls back to 8000/3000), and make sure no other worktree holds them. Register
the offset ports with the provider only if you genuinely need two OAuth-capable
worktrees at once.
mise run wt:env hashes the worktree path → an offset (a multiple of 10, 10–1990)
and writes .env.worktree at the repo root. mise auto-loads that file for every
task via [env]._.file; the main worktree has no file (offset 0) and keeps
today's defaults. Delete .env.worktree to fall back to defaults.
| Service | Var | Value | Main (offset 0) |
|---|---|---|---|
| API (uvicorn) | API_PORT |
8000 + offset |
8000 |
API (dockered, dev:vm) |
API_HOST_PORT |
8000 + offset |
8000 |
| Web (Next.js) | WEB_PORT |
3000 + offset |
3000 |
| API → web URL (redirects, links) | FRONTEND_URL |
http://localhost:$WEB_PORT |
…:3000 |
| Web → API URL | NEXT_PUBLIC_API_BASE_URL |
http://localhost:$API_PORT/api/v1/ |
…8000/api/v1/ |
Bots/gaia-sim → API URL |
GAIA_API_URL |
http://localhost:$API_PORT |
…:8000 |
Scripted LLM stub (dev --sim) |
LLM_STUB_PORT |
9797 + offset |
9797 |
| Discord bot | BOT_DISCORD_PORT |
3200 + offset |
3200 |
| Slack bot | BOT_SLACK_PORT |
3201 + offset |
3201 |
| Telegram bot | BOT_TELEGRAM_PORT |
3202 + offset |
3202 |
| WhatsApp bot | BOT_WHATSAPP_PORT |
3203 + offset |
3203 |
NEXT_PUBLIC_API_BASE_URL is exported into the process env, which Next.js ranks
above apps/web/.env.local (Next's load order puts process.env first), so the
web app in a worktree talks to that worktree's own API even though .env.local
was copied from main.
The offset is deterministic per path but two paths can theoretically hash to the
same offset. If two live worktrees collide, rename one (wt switch to a
differently-named branch/dir) and re-run mise run wt:env.
Single-instance bots — do NOT run the same bot twice
Discord, Telegram, Slack, and WhatsApp each authenticate with one bot token.
Running the same bot in two worktrees makes both connect with that token:
Telegram returns 409 Conflict (getUpdates), Discord/Slack sessions fight, and
webhook deliveries get split. The per-worktree bot ports only stop the local
HTTP health/webhook servers from colliding — they do not give you a second bot
identity.
Rule: run any given bot in at most one worktree at a time. mise dev (API +
web only) never starts a bot, so most parallel work is unaffected. If you must
run a bot in a non-main worktree, stop it in the other worktree first. To point a
single bot's health/webhook server at this worktree's port, set the bot's single
override explicitly, e.g. BOT_SERVER_PORT=$BOT_WHATSAPP_PORT nx dev bot-whatsapp
(BOT_SERVER_PORT overrides all four defaults, so set it per single-bot launch,
not globally).
Shared database — one seeded dev user at a time
Ports are per-worktree; the Docker infra (Mongo/Postgres/Redis/RabbitMQ/Chroma)
is shared. So anything that writes to a fixed key collides across worktrees.
The web e2e suite is the sharp edge: its global-setup resets → mints → seeds
DEV_USER (default dev@gaia.local), so two worktrees running mise e2e:web
at once wipe each other's data mid-run.
Rule: run e2e in one worktree at a time, or give a worktree its own identity
by setting DEV_USER=<email> — the --agent / --sim flags on mise dev /
dev:vm derive the API's DEV_AUTH_BYPASS_EMAIL from DEV_USER, so a single
value sets both the e2e seed target and the server's bypass identity. They must
match because browser page loads carry no X-Dev-User header, so the seeded user
has to equal the server's bypass email. Full per-worktree DB isolation is
deferred; until then this is the constraint.
Merge back
Repo git rules apply (see root CLAUDE.md):
masteris the single base branch — branch from and merge intomaster. Ifwt switch -ccreates branches from anything else, the default-branch state is wrong on this clone: runwt config state default-branch set master(see One-time setup).- Plain merge only. Never
git rebase/git pull --rebaseagainstorigin/master. - Never merge PRs (
gh pr mergeandwt mergeare both off-limits) — the team merges.
Hand-off flow: sync with master, push the branch, open a PR:
git fetch origin && git merge origin/master && git push -u origin HEAD
wt list # show all worktrees + status
wt remove # remove the current worktree; deletes the branch if merged
Troubleshooting
port NNNN is already in use by PID …onmise dev(incl.--sim) — the preflight (scripts/dev/check-ports.sh) refused to start because another worktree or a stale server holds the port; the message names the process. Kill it, or confirm.env.worktreeexists here (cat .env.worktree) and re-runmise run wt:env. Offset collision between two worktrees → rename one and re-runwt:env. Without the preflight this failed silently: uvicorn exited, nx kept web alive, and every request went to the other worktree's API.- Web calls the wrong API — check
NEXT_PUBLIC_API_BASE_URLin.env.worktreematches this worktree'sAPI_PORT; restartnx dev webso Next re-reads the env. .env.worktreemissing — runmise run wt:env. A brand-new worktree gets it from the pre-start hook; running the task again is safe and idempotent.no task //:wt:env foundduringwt switch -c— the branch predates the worktree infra (added Jul 2026 in7f3cd4115), so itsmise.tomllacks thewt:envtask, the_.file = ".env.worktree"auto-load, and the--port=${WEB_PORT:-3000}dev script. Run the port script manually (orgit merge origin/masterto bring the infra in), then export the port when running the dev server:pnpm nx run web:next:dev --port=3040.- New worktree has no secrets — the
copy-ignoredhook only copies files that are both gitignored and in.worktreeinclude. If you added a new secret file, add its path to.worktreeincludeand re-runwt step copy-ignored. JuiceFSUnavailable— unrelated to worktrees; nativemise devcannot mount JuiceFS. Usemise dev:vmfor workspace-v2 / file-upload / sandbox work (seeapps/api/CLAUDE.md).- Telegram
409/ bot fighting — the same bot is running in another worktree. Stop it there. See the single-instance section above.