This runbook is for running Dispatch reliably across agent/session boundaries and host restarts.
Dispatch runs as a launchd LaunchAgent (com.dispatch.server) — a macOS-native service manager that:
- Starts automatically at login
- Restarts automatically if the process crashes
- Runs as the current user with full environment access
- Cannot be accidentally stopped by agents working in the repo
The server lives in a separate checkout at ~/.dispatch/server/ (independent from your working copy at ~/dev/apps/dispatch). This means git checkout, deploys, and agent activity in the main repo never interfere with the running server.
The server runs as a compiled Bun binary (dist/bun/dispatch-<version>-bun-<platform>-<arch>). bin/dispatch-launchd-wrapper selects the most recent matching binary for the host platform/arch and execs it. The host needs bun and pnpm only when building from source — not just to run the service.
Postgres runs via Homebrew (brew services start postgresql@17, port 5432). Docker is available for isolated dev databases via dispatch-dev.
Server port: 6767 (set via DISPATCH_PORT in ~/.dispatch/server/.env).
Per-install state lives outside the repo checkout in ~/.dispatch/:
release.json— the currently deployed tag anddeployedAttimestampassisted-update.json— in-progress assisted-update state (token, phase, checks, notes)applied-migrations.json— install-update migration ids that have been applied locally (CRU-146)cache/release-<tag>.tar.gz— cached pre-built release artifacts (override dir withDISPATCH_RELEASE_CACHE_DIR)logs/dispatch.log— service stdout/stderr (rotated by the server, see Diagnostics)diagnostics/— periodic tmux inventory + missing-session incident bundles
The simplest way to manage the running service is the bin/dispatch-server wrapper in the production checkout. It resolves the configured port from ~/.dispatch/server/.env, runs a TLS-aware health check, and tails the log on failure:
cd ~/.dispatch/server
bin/dispatch-server status # launchd state + health check JSON
bin/dispatch-server start # bootstrap (or kickstart if already loaded)
bin/dispatch-server stop # bootout
bin/dispatch-server restart # kickstart -k
bin/dispatch-server logs # tail -n 200 ~/.dispatch/logs/dispatch.log
bin/dispatch-server logs -f # follow
bin/dispatch-server build # pnpm install + pnpm run build:bun
bin/dispatch-server update # build + restart + health checkEquivalent raw launchctl commands for scripting:
# Service state
launchctl print "gui/$(id -u)/com.dispatch.server"
# Live logs
tail -f ~/.dispatch/logs/dispatch.log
# Load (first-time after install) / unload
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.dispatch.server.plist
launchctl bootout "gui/$(id -u)/com.dispatch.server"
# Restart in place (preserves the bootstrap; what `restart` and the release
# flow use)
launchctl kickstart -k "gui/$(id -u)/com.dispatch.server"launchctl load / unload against the plist path also work and are still emitted by bin/install-launchd / bin/uninstall-launchd.
Production uses Homebrew Postgres (native, no Docker overhead):
brew services start postgresql@17 # start (auto-starts at boot)
brew services stop postgresql@17 # stop
pg_isready # check statusFor development, dispatch-dev up creates an isolated Docker Postgres container on a free port — no manual setup needed.
Releases are triggered from the Dispatch UI in Settings → Releases (release admin only — gh repo view --json viewerPermission must be ADMIN). The server handles the job internally via POST /api/v1/release:
- Verifies the GitHub CLI (
gh) is available - Resolves the upstream repo from the production checkout's
originremote - Triggers the
.github/workflows/release.ymlworkflow viagh workflow run - Streams
gh run watchfor the spawned run id; phase moves throughpreflight→triggering→watching - On workflow success: pulls the latest tag from the repo and reports
done - On workflow failure: sets phase to
failedwith the run URL in the error string
The actual deploy of that new tag is a separate operator step (the UI offers an "Update to vX.Y.Z" button on the Releases section, which calls POST /api/v1/release/update).
# Trigger a patch release from the API
curl -X POST http://127.0.0.1:6767/api/v1/release \
-H 'Content-Type: application/json' \
-d '{"versionType":"patch"}'The release workflow (GitHub Actions):
- Runs
pnpm run ci(format, type-check, lint, build, unit tests, e2e) against an ephemeral Postgres container - Bumps the version in the root
package.json, every workspace package (apps/*/package.json), and the lockfile - Generates
release-notes/current.mdfrom GitHub's auto-generated notes - Commits the version bump and notes, creates a git tag, and pushes
- Builds Bun binaries for each host platform/arch and packs them into
dispatch-release.tar.gzviabin/pack-release - Publishes a pre-release GitHub Release with the tarball attached. Releases are promoted to non-prerelease (the "stable" channel) via
POST /api/v1/release/promoteonce they soak.
# Update to a specific tag (also used for rollback)
curl -X POST http://127.0.0.1:6767/api/v1/release/update \
-H 'Content-Type: application/json' \
-d '{"tag":"v1.2.3"}'The server update flow operates on ~/.dispatch/server/ and:
- Gates the request. If the target release has pending update-migrations (CRU-146) or its body declares
mode: requiredin adispatch-updateblock (and the install isn't already pastappliesFrom), responds409withASSISTED_UPDATE_REQUIRED— caller must use/release/assisted/launchinstead, or passforce: trueto override. Returns503MIGRATION_EVALUATION_UNAVAILABLEif the migration evaluator can't reach the tarball (transient — retry). - Fetches tags from origin and verifies the target ref exists.
- Deploys. Tries the artifact-first path: downloads
dispatch-release.tar.gzvia direct HTTPS from the GitHub release into the tarball cache (~/.dispatch/cache/release-<tag>.tar.gz), validates entries are not absolute /..-traversing, extracts into~/.dispatch/server/. If the artifact can't be downloaded or extracted, falls back togit checkout <tag> && pnpm install --frozen-lockfile && pnpm run build:bun. - Verifies the runtime binary matches
dist/bun/dispatch-*-bun-<platform>-<arch>and writes the new tag to~/.dispatch/release.json. - Restarts the service by detaching
launchctl kickstart -k gui/$(id -u)/com.dispatch.server(orsystemctl --user restart dispatchon Linux). The new process binds the port itself; the standard update flow does not poll for health afterwards. Usebin/dispatch-server status(or hit/api/v1/health) to confirm.
If the update fails mid-flight (e.g. archive extraction error, missing binary), the job's phase is set to failed with the error in the SSE stream and the active job state. There is no automatic rollback in the standard flow — re-issue the update against the previous tag manually, or use the assisted-update flow which can drive rollback.
# Roll back to a previously deployed tag
curl -X POST http://127.0.0.1:6767/api/v1/release/update \
-H 'Content-Type: application/json' \
-d '{"tag":"v1.2.2"}'Rollback is just an update to an older tag. The currently deployed tag is whatever was last written to ~/.dispatch/release.json:
cat ~/.dispatch/release.jsonIf the failed update went through the assisted-update flow, the launched agent can drive rollback explicitly (it has the bearer token and follows the recovery guidance in its initial prompt). The state lands in ~/.dispatch/assisted-update.json with phase: "rollback" if it goes that route.
For releases that have pending install-update migrations (CRU-146) or carry an assisted-update block in the release body, updates run via a dedicated flow that spawns a full-access agent on the production checkout to perform the work step-by-step.
# Launch the assisted-update flow for a tag
curl -X POST http://127.0.0.1:6767/api/v1/release/assisted/launch \
-H 'Content-Type: application/json' \
-d '{"tag":"v1.2.3"}'
# Inspect persisted state
curl -s http://127.0.0.1:6767/api/v1/release/assisted/state | jq
# Clear stuck state
curl -X DELETE http://127.0.0.1:6767/api/v1/release/assisted/stateWhat the launch endpoint does:
- Picks the first enabled CLI agent type (
claude/codex/cursor/opencode) — terminal-type agents can't drive assisted updates. - Snapshots pending update-migrations from
update-migrations/*.yamlin the target tarball, or falls back to thedispatch-updateblock in the release body. - Creates an
assisted_updateagent rooted at~/.dispatch/server/withfullAccess: true, no worktree, and an initial prompt that includes recovery instructions plus a bearer token (DISPATCH_RELEASE_UPDATE_TOKEN). - Persists
~/.dispatch/assisted-update.jsonwith the token, target tag, snapshotted manifests, and the required-checks list.
The agent reports progress by POST /api/v1/release/assisted/phase with its bearer token. The phase axis is:
inspect → prepare → apply → restarting → validate → done
↓
rollback / blocked / failed (terminal)
When the agent moves to validate, the server runs runAndRecordChecks, which evaluates the required checks listed in the manifests/metadata. The known check names (defined in apps/server/src/release-metadata.ts):
expected_runtime_artifact— the platform-matchingdist/bun/dispatch-*binary exists after deployservice_entrypoint—apps/server/package.jsonhas ascripts.startentryservice_restarted—~/.dispatch/release.jsonis present (lenient proxy for restart; the deeper check ishealth_endpoint)version_converged—~/.dispatch/release.jsontag matches the target taghealth_endpoint— the new process is serving HTTP/HTTPS withstatus: "ok"
When the agent succeeds, the server writes the manifest ids into ~/.dispatch/applied-migrations.json so the gate stops firing for them on the next update.
Authoring assisted-update metadata in release notes is documented in release-notes/AUTHORING.md. Migration manifests use the schema in apps/server/src/update-migrations.ts; existing examples live in update-migrations/.
Every PR to main triggers .github/workflows/ci.yml:
- Sets up
pnpm,bun,node, and an ephemeral Postgres 17 container - Installs Playwright Chromium
- Validates
release-notes/next-assisted-update.json(when present) viabin/embed-assisted-update.ts --check-only - Runs
pnpm run ci, which isformat && check && lint:web && build && test && test:e2e - Builds Bun binaries (
pnpm run build:bun) and smoke-tests the host-platform binary
PRs must pass CI before merge.
Run the preflight check first to verify all dependencies are present:
bin/preflightThis checks required dependencies (git, postgresql, tmux) and optional ones (gh, claude, codex, opencode, playwright, xcode, docker). Fix any failures before proceeding.
To install Dispatch as a launchd service on a new machine:
# Install on default port (6767)
bin/install-launchd
# Install on a custom port
bin/install-launchd --port 6767This script:
- Clones the repo (
originof the current working copy) to$DISPATCH_SERVER_DIR(default~/.dispatch/server/) - Copies
.env(or.env.example) as~/.dispatch/server/.env, then writes the resolvedDISPATCH_PORT - Checks out the latest semver-tagged release in the server checkout
- Deploys: tries
gh release download <tag> dispatch-release.tar.gzfrom$DISPATCH_REPO(defaultselfcontained/dispatch) and extracts the prebuiltdist/bun/binaries into the server checkout. Falls back topnpm install --frozen-lockfile && pnpm run build:bunifghisn't available, the artifact is missing, or no release tag exists. - Seeds
~/.dispatch/release.jsonso the install knows what tag it's running and update detection has a baseline - Writes
~/Library/LaunchAgents/com.dispatch.server.plist(withRunAtLoad+KeepAlive, pointing atbin/dispatch-launchd-wrapper) and loads it vialaunchctl load
After installation, edit ~/.dispatch/server/.env to set DATABASE_URL and any API keys, then bin/dispatch-server restart to pick the changes up.
bin/uninstall-launchdUnloads and removes the plist. Does not remove ~/.dispatch/server/, the Homebrew Postgres data directory, or any dispatch-dev Docker data created for isolated development environments.
Server configuration lives in ~/.dispatch/server/.env. Key variables:
| Variable | Default | Description |
|---|---|---|
DISPATCH_HOST |
127.0.0.1 |
Interface to bind the API server to. Set 0.0.0.0 only when the machine must accept remote connections. |
DISPATCH_PORT |
6767 |
HTTP port the server listens on |
DATABASE_URL |
postgres://dispatch:[email protected]:5432/dispatch |
Postgres connection string |
MEDIA_ROOT |
~/.dispatch/media |
File upload storage path |
DISPATCH_AGENT_RUNTIME |
tmux |
Agent runtime mode (tmux or inert for dev/test) |
DISPATCH_COPY_DISPLAY |
— | Virtual X display for clipboard image paste on Linux (e.g. :99) |
TLS_CERT |
— | Path to TLS certificate file (enables HTTPS when both cert and key are set) |
TLS_KEY |
— | Path to TLS private key file |
Changes to .env require a service restart to take effect.
Health check:
curl -s http://127.0.0.1:6767/api/v1/health | jqGit context troubleshooting:
gitContext is populated synchronously at agent creation, setup-complete, and every restart, then pushed to clients via SSE. There is no periodic refresh loop or diagnostics endpoint — to inspect what the server has stored, query the agents table directly:
psql "$DATABASE_URL" -c "SELECT id, name, git_context, git_context_stale, git_context_updated_at FROM agents WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 20;"What to look for:
git_context_stale = true: the most recent probe errored (worktree missing, repo permissions, etc.); the priorgit_contextvalue is preserved for displaygit_context IS NULLfor a worktree-backed agent: a probe error happened before any successful probe, or the agent predates inline-populate and has not been restarted since the upgradegit_context_updated_atfar older thanupdated_at: the agent has not gone through a lifecycle event that re-probes — restart the agent to refresh
If agents were running and then suddenly reconcile changed them to stopped, start here.
Dispatch now writes host-side tmux diagnostics to:
~/.dispatch/diagnostics/tmux-inventory.jsonl
~/.dispatch/diagnostics/*-missing-session-<agentId>.jsonWhat these files mean:
tmux-inventory.jsonl: periodic snapshots taken during reconcile*-missing-session-<agentId>.json: incident bundle written when reconcile expects a tmux session buttmux has-sessionfails
Recommended incident workflow:
- Confirm what Dispatch observed.
tail -n 200 ~/.dispatch/logs/dispatch.logLook for lines like:
status corrected to stoppedAgent process exited with code ...- repeated reconcile corrections across multiple agents in the same minute
- Inspect the most recent missing-session incident bundle.
ls -1t ~/.dispatch/diagnostics/*-missing-session-*.json | head
jq . ~/.dispatch/diagnostics/<timestamp>-missing-session-<agentId>.jsonImportant fields:
agent: which agent was affected, what status it had, and whether an exit code file existedtmux.serverPid: whether Dispatch could still find a tmux server processtmux.sessionsandtmux.panes: whethertmux list-sessions/list-panesstill worked at incident timeprocesses.stdout: point-in-time process listlaunchctl.stdout: currentcom.dispatch.serverlaunchd state
- Check whether the tmux server disappeared entirely or just Dispatch sessions.
tail -n 20 ~/.dispatch/diagnostics/tmux-inventory.jsonl | jq .What to look for:
serverPidchanged or becamenull: tmux server likely exited or was killedsessions.exitCodechanged from0to1: tmux had no reachable server/socket- non-Dispatch sessions still present but Dispatch sessions gone: cleanup bug or targeted session removal
- all sessions gone at once: host/session-level event is more likely than app logic
- Check launchd state for the server itself.
launchctl print gui/$(id -u)/com.dispatch.serverWhat to look for:
last exit codelast terminating signal- recent restart timing that lines up with the incident
- Pull macOS unified logs around the incident window.
Use a tight window around when the sessions disappeared.
log show --style compact --start "2026-03-13 12:57:30" --end "2026-03-13 12:59:30" --predicate '(process == "tmux") || (process == "launchd") || (eventMessage CONTAINS[c] "com.dispatch.server") || (eventMessage CONTAINS[c] "logout") || (eventMessage CONTAINS[c] "Aqua")'If needed, run narrower follow-ups:
log show --style compact --start "<start>" --end "<end>" --predicate '(process == "kernel") || (eventMessage CONTAINS[c] "SIGKILL") || (eventMessage CONTAINS[c] "killed") || (eventMessage CONTAINS[c] "jetsam")'
log show --style compact --start "<start>" --end "<end>" --predicate '(process == "loginwindow") || (eventMessage CONTAINS[c] "logout") || (eventMessage CONTAINS[c] "user session")'Interpretation:
- Dispatch restarted but tmux stayed up: app restart only, agent sessions should have survived
- Dispatch and tmux both disappeared: something outside Dispatch likely killed a broader user-scoped context
- logout / Aqua / loginwindow activity: user session event likely killed tmux
SIGKILLor kernel kill messages near the same time: external kill or resource pressure
- Check for same-user interference.
If self-hosted GitHub Actions runners or other automation run under the same macOS user, treat that as a suspect until proven otherwise. tmux and launchd state are user-scoped, so same-user automation has a much larger blast radius than automation running under a separate account.
Known limits:
- Dispatch can now tell you much more about what the host looked like when sessions vanished
- Dispatch still cannot prove the killer if macOS did not log it or if the evidence aged out before inspection
- If the host logged out, rebooted, or aggressively reaped processes, unified logs are still the source of truth
| Script | Description |
|---|---|
bin/dispatch-server |
Service management wrapper (start, stop, restart, status, logs, build, update) |
bin/dispatch-launchd-wrapper |
Selects the correct Bun binary for the host platform/arch and execs it |
bin/dispatch-dev |
Dev environment manager (isolated Docker Postgres + API server + Vite frontend) |
bin/dispatch-stream |
Agent-side CLI for managing browser streams (start --playwright <port> / stop "<description>") |
bin/install-launchd |
First-time install: clones repo, deploys release artifact, writes launchd plist |
bin/uninstall-launchd |
Unloads and removes the launchd plist |
bin/preflight |
Pre-install dependency checker (required: git, postgresql, tmux; optional: gh, CLIs, playwright, docker) |
bin/pack-release |
Packs dispatch-release.tar.gz from pre-built Bun binaries; used by the release workflow |
bin/embed-assisted-update.ts |
Validates assisted-update metadata in release-notes/next-assisted-update.json |
| Path | Description |
|---|---|
~/.dispatch/server/ |
Server checkout (deploy target) |
~/.dispatch/server/.env |
Server environment config |
~/.dispatch/server/dist/bun/ |
Compiled Bun binaries the wrapper execs |
~/.dispatch/release.json |
Currently deployed tag + deployedAt timestamp |
~/.dispatch/assisted-update.json |
In-progress assisted-update state (token, phase, checks, notes) |
~/.dispatch/applied-migrations.json |
Install-update migration ids that have been applied locally (CRU-146) |
~/.dispatch/cache/release-<tag>.tar.gz |
Cached pre-built release artifacts keyed by tag |
~/.dispatch/logs/dispatch.log |
Live server log (rotated via copy-truncate at 10 MB; backups kept 14 days) |
~/.dispatch/diagnostics/tmux-inventory.jsonl |
Periodic tmux inventory snapshots from reconcile (rotated at 10 MB; 7 days) |
~/.dispatch/diagnostics/*-missing-session-<agentId>.json |
Incident bundle for missing tmux sessions (pruned after 7 days) |
~/Library/LaunchAgents/com.dispatch.server.plist |
launchd service definition (points at bin/dispatch-launchd-wrapper) |