From 49d05a158869a4b9a3856df209efb865b246b0bb Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 12 Mar 2026 16:26:03 -0600 Subject: [PATCH 1/6] Add dispatch-dev for isolated agent dev environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents running `npm run dev` directly could kill their own process when restarting dev servers (the tmux session and agent CLI share a process tree). dispatch-dev runs DB, API, and Vite in sibling tmux windows tied to the agent session — automatic lifecycle cleanup on agent stop/delete, no orphaned servers, no port conflicts. - bin/dispatch-dev: bootstraps isolated Postgres container + API + optional Vite, all auto-assigned ports, zero-config for agents - AgentManager.cleanupDevEnvironment: removes Docker containers and state files on agent stop/delete - docker-compose.yml: ephemeral volumes per container (no shared pgdata) - CLAUDE.md/AGENTS.md: consolidated dev docs around dispatch-dev - scripts/e2e-isolated.sh + playwright TLS/isolated support Co-Authored-By: Claude Opus 4.6 --- .dispatch/config.json | 2 +- AGENTS.md | 36 ++-- CLAUDE.md | 36 ++-- bin/dispatch-dev | 361 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 13 +- package.json | 3 +- playwright.config.ts | 14 +- scripts/e2e-isolated.sh | 46 +++++ src/agents/manager.ts | 26 ++- 9 files changed, 499 insertions(+), 38 deletions(-) create mode 100755 bin/dispatch-dev create mode 100755 scripts/e2e-isolated.sh diff --git a/.dispatch/config.json b/.dispatch/config.json index c7a52dc5..ab871b7a 100644 --- a/.dispatch/config.json +++ b/.dispatch/config.json @@ -1,5 +1,5 @@ { - "worktreeMode": "auto", + "worktreeMode": "ask", "linear": { "team": "CrumbStream", "teamKey": "CRU", diff --git a/AGENTS.md b/AGENTS.md index 780e9e0c..17bef9d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,32 +33,44 @@ Before marking any task as done, run the following checks and fix any failures: 1. **Type checking**: `npm run check` (runs `tsc --noEmit` for backend + web). 2. **Web finalization**: If any files under `web/` changed, run `npm run finalize:web` (type check + production build). -3. **E2E tests**: `npm run test:e2e` (Playwright). Use `E2E_PORT=` if a dev server is already running. +3. **E2E tests**: `npm run test:e2e` (Playwright). If a dev server is running via `dispatch-dev`, pass its port: `E2E_PORT= npm run test:e2e`. 4. **Unit tests**: `npm test` (Vitest) if backend logic changed. - Do not consider a task complete until all applicable checks pass. - If a pre-existing test is flaky (fails before your changes too), note it in your response but do not skip the rest of the suite. ## Web Finalization - If any files under `web/` changed, run `npm run finalize:web` before marking the task complete. -- After running `npm run finalize:web`, verify the served app once (not only Vite dev server) when the task affects UI/theme/rendering behavior. - -## Vite Dev Server -- For frontend development/validation, run the Vite dev server (`npm --prefix web run dev`) instead of the backend static server. -- Do not pin a fixed Vite port unless explicitly requested; let Vite choose an open port automatically. -- If multiple local Dispatch instances are running, always use the exact URL printed by the active Vite process for Playwright/manual checks. +- After running `npm run finalize:web`, verify the served app via `dispatch-dev` when the task affects UI/theme/rendering behavior. ## Temporary Files - Never write temporary files (screenshots, test scripts, scratch files) to the repo root. - Use `/tmp/` or `$DISPATCH_MEDIA_DIR` for ephemeral files. - Playwright screenshots should be published via `dispatch-share`, not saved locally. +## Dev Server Management (CRITICAL) +- **NEVER run `npm run dev` directly** in your terminal — it will block your session and killing it can kill your agent process. +- **NEVER use `pkill`, `killall`, or `lsof | xargs kill`** to manage dev servers — these can kill your own agent process. +- Use `dispatch-dev` to manage dev environments. It spins up an isolated DB, API server, and optionally Vite, all tied to your agent session. Everything is automatically cleaned up when your session ends. + ```bash + dispatch-dev up # start isolated DB + API server + dispatch-dev up --vite # also start Vite frontend + dispatch-dev up --cwd /path/to/worktree # start from a specific directory + dispatch-dev up --no-db # skip DB (use existing DATABASE_URL) + dispatch-dev down # tear down everything + dispatch-dev restart # restart the environment + dispatch-dev status # check what's running + dispatch-dev logs # API server logs + dispatch-dev logs --vite # Vite server logs + dispatch-dev url # print the API server URL + ``` +- `dispatch-dev up` auto-selects free ports and prints the URLs — just use the printed URLs. + ## Backend Testing Safety - Treat `127.0.0.1:6767` as production by default; do not stop or kill the existing production server for ad-hoc testing. -- When backend changes need local validation, run a separate backend instance on a different port (for example `DISPATCH_PORT=8788 npm run dev`) and point validation tooling to that port. +- When backend changes need local validation, use `dispatch-dev up` and point validation tooling to the printed URL. - Only operate on production (`:6767`) when explicitly requested by the user. ## Development Database -- Production uses the `dispatch` database. Development and worktrees use `dispatch_dev`. -- When running dev servers, set `DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:5432/dispatch_dev` to avoid touching production data. -- Worktree `.env` files should already be configured with the dev database, dev port (8788), and a separate media root (`~/.dispatch/media-dev`). -- Migrations run automatically on server start, so the dev database schema stays up to date. +- Production uses the `dispatch` database. Never connect to it from dev servers. +- `dispatch-dev up` creates an isolated Postgres container per agent with its own port — no manual DATABASE_URL setup needed. +- Migrations run automatically on API server start. diff --git a/CLAUDE.md b/CLAUDE.md index c2d73c65..c3c96839 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,32 +34,44 @@ Before marking any task as done, run the following checks and fix any failures: 1. **Type checking**: `npm run check` (runs `tsc --noEmit` for backend + web). 2. **Web finalization**: If any files under `web/` changed, run `npm run finalize:web` (type check + production build). -3. **E2E tests**: `npm run test:e2e` (Playwright). Use `E2E_PORT=` if a dev server is already running. +3. **E2E tests**: `npm run test:e2e` (Playwright). If a dev server is running via `dispatch-dev`, pass its port: `E2E_PORT= npm run test:e2e`. 4. **Unit tests**: `npm test` (Vitest) if backend logic changed. - Do not consider a task complete until all applicable checks pass. - If a pre-existing test is flaky (fails before your changes too), note it in your response but do not skip the rest of the suite. ## Web Finalization - If any files under `web/` changed, run `npm run finalize:web` before marking the task complete. -- After running `npm run finalize:web`, verify the served app once (not only Vite dev server) when the task affects UI/theme/rendering behavior. - -## Vite Dev Server -- For frontend development/validation, run the Vite dev server (`npm --prefix web run dev`) instead of the backend static server. -- Do not pin a fixed Vite port unless explicitly requested; let Vite choose an open port automatically. -- If multiple local Dispatch instances are running, always use the exact URL printed by the active Vite process for Playwright/manual checks. +- After running `npm run finalize:web`, verify the served app via `dispatch-dev` when the task affects UI/theme/rendering behavior. ## Temporary Files - Never write temporary files (screenshots, test scripts, scratch files) to the repo root. - Use `/tmp/` or `$DISPATCH_MEDIA_DIR` for ephemeral files. - Playwright screenshots should be published via `dispatch-share`, not saved locally. +## Dev Server Management (CRITICAL) +- **NEVER run `npm run dev` directly** in your terminal — it will block your session and killing it can kill your agent process. +- **NEVER use `pkill`, `killall`, or `lsof | xargs kill`** to manage dev servers — these can kill your own agent process. +- Use `dispatch-dev` to manage dev environments. It spins up an isolated DB, API server, and optionally Vite, all tied to your agent session. Everything is automatically cleaned up when your session ends. + ```bash + dispatch-dev up # start isolated DB + API server + dispatch-dev up --vite # also start Vite frontend + dispatch-dev up --cwd /path/to/worktree # start from a specific directory + dispatch-dev up --no-db # skip DB (use existing DATABASE_URL) + dispatch-dev down # tear down everything + dispatch-dev restart # restart the environment + dispatch-dev status # check what's running + dispatch-dev logs # API server logs + dispatch-dev logs --vite # Vite server logs + dispatch-dev url # print the API server URL + ``` +- `dispatch-dev up` auto-selects free ports and prints the URLs — just use the printed URLs. + ## Backend Testing Safety - Treat `127.0.0.1:6767` as production by default; do not stop or kill the existing production server for ad-hoc testing. -- When backend changes need local validation, run a separate backend instance on a different port (for example `DISPATCH_PORT=8788 npm run dev`) and point validation tooling to that port. +- When backend changes need local validation, use `dispatch-dev up` and point validation tooling to the printed URL. - Only operate on production (`:6767`) when explicitly requested by the user. ## Development Database -- Production uses the `dispatch` database. Development and worktrees use `dispatch_dev`. -- When running dev servers, set `DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:5432/dispatch_dev` to avoid touching production data. -- Worktree `.env` files should already be configured with the dev database, dev port (8788), and a separate media root (`~/.dispatch/media-dev`). -- Migrations run automatically on server start, so the dev database schema stays up to date. +- Production uses the `dispatch` database. Never connect to it from dev servers. +- `dispatch-dev up` creates an isolated Postgres container per agent with its own port — no manual DATABASE_URL setup needed. +- Migrations run automatically on API server start. diff --git a/bin/dispatch-dev b/bin/dispatch-dev new file mode 100755 index 00000000..5c5392af --- /dev/null +++ b/bin/dispatch-dev @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +set -euo pipefail + +# dispatch-dev — bootstrap and manage a full dev environment for an agent. +# +# Starts an isolated Postgres DB, API server, and optionally a Vite frontend, +# all running in tmux windows within the agent's session. Everything is +# automatically cleaned up when the agent is stopped or deleted. +# +# Agents don't need to know about tmux, ports, or Docker — just: +# dispatch-dev up # spin up everything +# dispatch-dev down # tear it all down +# dispatch-dev logs [api|vite|db] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'USAGE' +Usage: dispatch-dev [options] + +Bootstrap a full isolated dev environment tied to the current agent session. + +Commands: + up Start DB + API server (+ Vite with --vite) + down Stop all dev services and remove the DB container + restart Tear down and re-create the environment + status Show which services are running + logs Show recent output (default: api; use --vite or --db) + url Print the dev server URL + +Options: + --cwd Working directory (default: current directory) + --vite Also start the Vite frontend dev server + --db-only Only start the database (skip API/Vite) + --no-db Skip database (use existing DATABASE_URL from .env) +USAGE +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +resolve_session() { + local agent_id="${DISPATCH_AGENT_ID:-${HOSTESS_AGENT_ID:-}}" + if [ -z "$agent_id" ]; then + echo "DISPATCH_AGENT_ID is not set. Run this inside a Dispatch agent session." >&2 + exit 1 + fi + echo "dispatch_${agent_id}" +} + +resolve_agent_id() { + echo "${DISPATCH_AGENT_ID:-${HOSTESS_AGENT_ID:-}}" +} + +has_window() { + local session="$1" name="$2" + tmux list-windows -t "$session" -F '#{window_name}' 2>/dev/null | grep -qx "$name" +} + +find_free_port() { + node -e ' + const net = require("net"); + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + console.log(srv.address().port); + srv.close(); + }); + ' +} + +nvm_init_snippet() { + if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then + echo "source '${NVM_DIR:-$HOME/.nvm}/nvm.sh' && nvm use >/dev/null 2>&1 && " + fi +} + +# Derive a short, docker-safe container suffix from the agent ID. +# The DB name inside the container is always "dispatch_dev" — isolation comes +# from each agent getting its own container on its own port. +db_container_suffix() { + local agent_id + agent_id="$(resolve_agent_id)" + echo "${agent_id#agt_}" +} + +state_file() { + local agent_id + agent_id="$(resolve_agent_id)" + echo "/tmp/dispatch-dev-${agent_id}.env" +} + +save_state() { + local file + file="$(state_file)" + cat > "$file" </dev/null; then + echo "Agent tmux session '$session' not found." >&2 + exit 1 + fi + + # --- Database --- + if [ "$OPT_NO_DB" != "1" ]; then + DEV_DB_NAME="$(db_container_suffix)" + DEV_DB_PORT="$(find_free_port)" + local container_name="dispatch-postgres-${DEV_DB_NAME}" + + echo "Starting database: ${container_name} on port ${DEV_DB_PORT}..." + + # Stop any existing container with this name + docker rm -f "$container_name" 2>/dev/null || true + + DISPATCH_DB_NAME="$DEV_DB_NAME" DISPATCH_DB_PORT="$DEV_DB_PORT" \ + docker compose -f "${SCRIPT_DIR}/docker-compose.yml" up -d 2>&1 + + # Wait for healthy + local attempts=0 + while [ $attempts -lt 30 ]; do + if docker exec "$container_name" pg_isready -U dispatch -d "dispatch_${DEV_DB_NAME}" >/dev/null 2>&1; then + echo "Database ready on port ${DEV_DB_PORT}" + break + fi + attempts=$((attempts + 1)) + sleep 1 + done + if [ $attempts -ge 30 ]; then + echo "Warning: database health check timed out" >&2 + fi + fi + + if [ "$OPT_DB_ONLY" = "1" ]; then + DEV_CWD="$cwd" + save_state + echo "" + echo "Database only mode. Set DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_DB_NAME}" + return + fi + + # --- API Server --- + DEV_API_PORT="$(find_free_port)" + DEV_CWD="$cwd" + + # Kill existing window if present + if has_window "$session" "dev-server"; then + tmux kill-window -t "${session}:dev-server" 2>/dev/null || true + fi + + local nvm_init + nvm_init="$(nvm_init_snippet)" + + # Build the env for the API server + local env_exports="" + if [ -f "${cwd}/.env" ]; then + env_exports="set -a && source '${cwd}/.env' && set +a && " + fi + + # Override port and DATABASE_URL to point at our container + local db_url_override="" + if [ "$OPT_NO_DB" != "1" ]; then + db_url_override="DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_DB_NAME} " + fi + + local api_cmd="${nvm_init}${env_exports}${db_url_override}DISPATCH_PORT=${DEV_API_PORT} npm run dev" + tmux new-window -t "$session" -n "dev-server" -c "$cwd" "$api_cmd" + + echo "API server starting on port ${DEV_API_PORT}" + + # --- Vite Frontend --- + if [ "$OPT_VITE" = "1" ]; then + DEV_VITE_PORT="$(find_free_port)" + + if has_window "$session" "vite-dev"; then + tmux kill-window -t "${session}:vite-dev" 2>/dev/null || true + fi + + local vite_cmd="${nvm_init}npm --prefix web run dev -- --port ${DEV_VITE_PORT}" + tmux new-window -t "$session" -n "vite-dev" -c "$cwd" "$vite_cmd" + + echo "Vite dev server starting on port ${DEV_VITE_PORT}" + fi + + save_state + + echo "" + echo "Dev environment ready:" + if [ "$OPT_NO_DB" != "1" ]; then + echo " db: postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_DB_NAME}" + fi + echo " api: http://127.0.0.1:${DEV_API_PORT}" + if [ "$OPT_VITE" = "1" ]; then + echo " web: http://127.0.0.1:${DEV_VITE_PORT}" + fi +} + +cmd_down() { + local session + session="$(resolve_session)" + + load_state + + # Kill tmux windows + for name in dev-server vite-dev; do + if has_window "$session" "$name"; then + tmux kill-window -t "${session}:${name}" 2>/dev/null || true + echo "Stopped ${name}" + fi + done + + # Stop the DB container + if [ -n "${DEV_DB_NAME:-}" ]; then + local container_name="dispatch-postgres-${DEV_DB_NAME}" + if docker ps -q -f "name=${container_name}" 2>/dev/null | grep -q .; then + docker rm -f "$container_name" 2>/dev/null || true + echo "Removed database container: ${container_name}" + fi + fi + + # Clean up state file + rm -f "$(state_file)" + + echo "Dev environment torn down." +} + +cmd_status() { + local session + session="$(resolve_session)" + + load_state + + # DB + if [ -n "${DEV_DB_NAME:-}" ]; then + local container_name="dispatch-postgres-${DEV_DB_NAME}" + if docker ps -q -f "name=${container_name}" 2>/dev/null | grep -q .; then + echo "db: running (${container_name}, port ${DEV_DB_PORT:-?})" + else + echo "db: stopped" + fi + else + echo "db: not managed" + fi + + # API + if has_window "$session" "dev-server"; then + echo "api: running (port ${DEV_API_PORT:-?})" + else + echo "api: stopped" + fi + + # Vite + if has_window "$session" "vite-dev"; then + echo "vite: running (port ${DEV_VITE_PORT:-?})" + else + echo "vite: stopped" + fi +} + +cmd_url() { + load_state + if [ -n "${DEV_API_PORT:-}" ]; then + echo "http://127.0.0.1:${DEV_API_PORT}" + else + echo "Dev server is not running." >&2 + exit 1 + fi +} + +cmd_logs() { + local session target_window + session="$(resolve_session)" + + if [ "$OPT_VITE" = "1" ]; then + target_window="vite-dev" + elif [ "$OPT_DB_ONLY" = "1" ]; then + # Show docker logs for the DB container + load_state + if [ -n "${DEV_DB_NAME:-}" ]; then + docker logs --tail 100 "dispatch-postgres-${DEV_DB_NAME}" 2>&1 + return + fi + echo "No managed database found." >&2 + exit 1 + else + target_window="dev-server" + fi + + if ! has_window "$session" "$target_window"; then + echo "No ${target_window} window found." >&2 + exit 1 + fi + + tmux capture-pane -p -S -200 -t "${session}:${target_window}" +} + +# --------------------------------------------------------------------------- +# Parse args +# --------------------------------------------------------------------------- + +OPT_CWD="" +OPT_VITE="0" +OPT_DB_ONLY="0" +OPT_NO_DB="0" +CMD="" + +while [ $# -gt 0 ]; do + case "$1" in + up|down|restart|status|logs|url) + CMD="$1"; shift ;; + # Keep backward compat with start/stop + start) CMD="up"; shift ;; + stop) CMD="down"; shift ;; + --cwd) + OPT_CWD="${2:-}"; shift 2 ;; + --vite) + OPT_VITE="1"; shift ;; + --db-only|--db) + OPT_DB_ONLY="1"; shift ;; + --no-db) + OPT_NO_DB="1"; shift ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown argument: $1" >&2 + usage; exit 1 ;; + esac +done + +case "${CMD:-}" in + up) cmd_up ;; + down) cmd_down ;; + restart) cmd_down 2>/dev/null; cmd_up ;; + status) cmd_status ;; + logs) cmd_logs ;; + url) cmd_url ;; + *) usage; exit 1 ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml index 16a31cea..f711c4ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,16 @@ services: postgres: image: postgres:17-alpine - container_name: dispatch-postgres + container_name: dispatch-postgres-${DISPATCH_DB_NAME:-dev} restart: unless-stopped environment: POSTGRES_USER: dispatch POSTGRES_PASSWORD: dispatch - POSTGRES_DB: dispatch + POSTGRES_DB: dispatch_${DISPATCH_DB_NAME:-dev} ports: - - "5432:5432" - volumes: - - dispatch_pgdata:/var/lib/postgresql/data + - "${DISPATCH_DB_PORT:-5433}:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatch"] + test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatch_${DISPATCH_DB_NAME:-dev}"] interval: 5s timeout: 3s retries: 10 - -volumes: - dispatch_pgdata: diff --git a/package.json b/package.json index 0ff469e3..f305ed72 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "check": "tsc --noEmit && npm run check:web", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npx playwright test" + "test:e2e": "npx playwright test", + "test:e2e:isolated": "bash scripts/e2e-isolated.sh" }, "dependencies": { "@fastify/multipart": "^8.3.1", diff --git a/playwright.config.ts b/playwright.config.ts index 4bf15a93..4e1e24bb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,17 @@ import { defineConfig } from "@playwright/test"; +if (process.env.TLS_CERT) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; +} + const devPort = process.env.E2E_PORT ?? "8788"; -const baseURL = `http://127.0.0.1:${devPort}`; +const protocol = process.env.TLS_CERT ? "https" : "http"; +const baseURL = `${protocol}://127.0.0.1:${devPort}`; const databaseUrl = process.env.DATABASE_URL ?? "postgres://dispatch:dispatch@127.0.0.1:5432/dispatch_dev"; +const mediaRoot = + process.env.MEDIA_ROOT ?? `${process.env.HOME}/.dispatch/media-dev`; export default defineConfig({ testDir: "./e2e", @@ -16,11 +23,14 @@ export default defineConfig({ use: { baseURL, headless: true, + ignoreHTTPSErrors: true, screenshot: "only-on-failure", trace: "retain-on-failure", }, webServer: { - command: `npm run build:web && DATABASE_URL=${databaseUrl} DISPATCH_PORT=${devPort} MEDIA_ROOT=$HOME/.dispatch/media-dev npm run dev`, + command: process.env.E2E_SKIP_WEB_BUILD + ? `DATABASE_URL=${databaseUrl} DISPATCH_PORT=${devPort} MEDIA_ROOT=${mediaRoot} npm run dev` + : `npm run build:web && DATABASE_URL=${databaseUrl} DISPATCH_PORT=${devPort} MEDIA_ROOT=${mediaRoot} npm run dev`, url: `${baseURL}/api/v1/health`, reuseExistingServer: !process.env.CI, timeout: 60_000, diff --git a/scripts/e2e-isolated.sh b/scripts/e2e-isolated.sh new file mode 100755 index 00000000..3f8d73a8 --- /dev/null +++ b/scripts/e2e-isolated.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Detect available compose command +if docker compose version &>/dev/null; then + COMPOSE="docker compose" +elif command -v docker-compose &>/dev/null; then + COMPOSE="docker-compose" +else + echo "Error: docker compose is not available. Install the Docker Compose plugin or docker-compose standalone." >&2 + exit 1 +fi + +# Generate unique identifiers based on PID to avoid collisions +RUN_ID="e2e-$$" +DB_PORT=$((RANDOM % 10000 + 10000)) +API_PORT=$((RANDOM % 10000 + 20000)) + +export DISPATCH_DB_NAME="$RUN_ID" +export DISPATCH_DB_PORT="$DB_PORT" +export E2E_PORT="$API_PORT" +export DATABASE_URL="postgres://dispatch:dispatch@127.0.0.1:${DB_PORT}/dispatch_${RUN_ID}" +export MEDIA_ROOT="/tmp/dispatch-media-${RUN_ID}" + +# Disable TLS so the e2e server runs plain HTTP +unset TLS_CERT TLS_KEY + +PROJECT="dispatch-${RUN_ID}" + +mkdir -p "$MEDIA_ROOT" + +cleanup() { + echo "==> Tearing down isolated environment" + $COMPOSE -p "$PROJECT" down -v 2>/dev/null || true + rm -rf "$MEDIA_ROOT" +} +trap cleanup EXIT + +echo "==> Starting isolated Postgres (project: ${PROJECT}, port: ${DB_PORT})" +$COMPOSE -p "$PROJECT" up -d --wait + +echo "==> Building web bundle" +npm run build:web + +echo "==> Running Playwright tests (API port: ${API_PORT})" +E2E_SKIP_WEB_BUILD=1 npx playwright test "$@" diff --git a/src/agents/manager.ts b/src/agents/manager.ts index afc63ff1..72b6ae47 100644 --- a/src/agents/manager.ts +++ b/src/agents/manager.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rm, stat } from "node:fs/promises"; +import { mkdir, readFile, rm, stat, unlink } from "node:fs/promises"; import path from "node:path"; import type { FastifyBaseLogger } from "fastify"; @@ -239,6 +239,8 @@ export class AgentManager { throw new AgentError(`Failed to stop agent: ${message}`, 500); } + await this.cleanupDevEnvironment(id); + return (await this.getAgent(id)) as AgentRecord; } @@ -259,6 +261,8 @@ export class AgentManager { await runCommand("tmux", ["kill-session", "-t", agent.tmuxSession]); } + await this.cleanupDevEnvironment(id); + await this.pool.query("DELETE FROM agents WHERE id = $1", [id]); const mediaDir = agent.mediaDir ?? path.join(this.config.mediaRoot, id); @@ -417,6 +421,26 @@ export class AgentManager { ); } + /** + * Clean up any dev environment resources (Docker containers, state files) + * created by dispatch-dev for this agent. + */ + private async cleanupDevEnvironment(agentId: string): Promise { + const stateFile = `/tmp/dispatch-dev-${agentId}.env`; + try { + const content = await readFile(stateFile, "utf-8"); + const dbNameMatch = content.match(/^DEV_DB_NAME=(.+)$/m); + if (dbNameMatch?.[1]) { + const containerName = `dispatch-postgres-${dbNameMatch[1]}`; + await runCommand("docker", ["rm", "-f", containerName], { allowedExitCodes: [0, 1] }); + this.logger.info({ agentId, containerName }, "Cleaned up dev database container"); + } + await unlink(stateFile).catch(() => {}); + } catch { + // No state file means no dev environment was created — nothing to clean up. + } + } + private async startTmuxSession( sessionName: string, cwd: string, From b9e38eff8ed15e00dd77417fa46e514e46c2ab89 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 12 Mar 2026 16:35:04 -0600 Subject: [PATCH 2/6] Make e2e tests always run in isolated environments npm run test:e2e now always spins up its own Postgres container and API server on random ports via e2e-isolated.sh. No more reuseExistingServer which could cause agents to accidentally share dev servers. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- package.json | 3 +-- playwright.config.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17bef9d3..5da44673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Before marking any task as done, run the following checks and fix any failures: 1. **Type checking**: `npm run check` (runs `tsc --noEmit` for backend + web). 2. **Web finalization**: If any files under `web/` changed, run `npm run finalize:web` (type check + production build). -3. **E2E tests**: `npm run test:e2e` (Playwright). If a dev server is running via `dispatch-dev`, pass its port: `E2E_PORT= npm run test:e2e`. +3. **E2E tests**: `npm run test:e2e` (Playwright). Always spins up its own isolated DB and server — safe to run alongside other agents. 4. **Unit tests**: `npm test` (Vitest) if backend logic changed. - Do not consider a task complete until all applicable checks pass. - If a pre-existing test is flaky (fails before your changes too), note it in your response but do not skip the rest of the suite. diff --git a/CLAUDE.md b/CLAUDE.md index c3c96839..d6a80862 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ Before marking any task as done, run the following checks and fix any failures: 1. **Type checking**: `npm run check` (runs `tsc --noEmit` for backend + web). 2. **Web finalization**: If any files under `web/` changed, run `npm run finalize:web` (type check + production build). -3. **E2E tests**: `npm run test:e2e` (Playwright). If a dev server is running via `dispatch-dev`, pass its port: `E2E_PORT= npm run test:e2e`. +3. **E2E tests**: `npm run test:e2e` (Playwright). Always spins up its own isolated DB and server — safe to run alongside other agents. 4. **Unit tests**: `npm test` (Vitest) if backend logic changed. - Do not consider a task complete until all applicable checks pass. - If a pre-existing test is flaky (fails before your changes too), note it in your response but do not skip the rest of the suite. diff --git a/package.json b/package.json index f305ed72..7cfe1f60 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,7 @@ "check": "tsc --noEmit && npm run check:web", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npx playwright test", - "test:e2e:isolated": "bash scripts/e2e-isolated.sh" + "test:e2e": "bash scripts/e2e-isolated.sh" }, "dependencies": { "@fastify/multipart": "^8.3.1", diff --git a/playwright.config.ts b/playwright.config.ts index 4e1e24bb..22bf78f2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ ? `DATABASE_URL=${databaseUrl} DISPATCH_PORT=${devPort} MEDIA_ROOT=${mediaRoot} npm run dev` : `npm run build:web && DATABASE_URL=${databaseUrl} DISPATCH_PORT=${devPort} MEDIA_ROOT=${mediaRoot} npm run dev`, url: `${baseURL}/api/v1/health`, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 60_000, }, }); From eebf61121484ed1fcd8ea65c9b8b77e8dbd2662c Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 12 Mar 2026 16:43:59 -0600 Subject: [PATCH 3/6] Fix e2e port collisions and non-blocking dev env cleanup - e2e-isolated.sh: use node net module to grab free ports from the OS instead of $RANDOM, preventing collisions between concurrent runs - cleanupDevEnvironment: fire-and-forget with 10s timeout on docker rm so it can't block agent stop/delete API calls Co-Authored-By: Claude Opus 4.6 --- scripts/e2e-isolated.sh | 17 ++++++++++++++--- src/agents/manager.ts | 6 +++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/e2e-isolated.sh b/scripts/e2e-isolated.sh index 3f8d73a8..f12c3abd 100755 --- a/scripts/e2e-isolated.sh +++ b/scripts/e2e-isolated.sh @@ -11,10 +11,21 @@ else exit 1 fi -# Generate unique identifiers based on PID to avoid collisions +# Grab actually-free ports from the OS to avoid collisions between concurrent runs +find_free_port() { + node -e ' + const net = require("net"); + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + console.log(srv.address().port); + srv.close(); + }); + ' +} + RUN_ID="e2e-$$" -DB_PORT=$((RANDOM % 10000 + 10000)) -API_PORT=$((RANDOM % 10000 + 20000)) +DB_PORT="$(find_free_port)" +API_PORT="$(find_free_port)" export DISPATCH_DB_NAME="$RUN_ID" export DISPATCH_DB_PORT="$DB_PORT" diff --git a/src/agents/manager.ts b/src/agents/manager.ts index 72b6ae47..0e2ce625 100644 --- a/src/agents/manager.ts +++ b/src/agents/manager.ts @@ -239,7 +239,7 @@ export class AgentManager { throw new AgentError(`Failed to stop agent: ${message}`, 500); } - await this.cleanupDevEnvironment(id); + this.cleanupDevEnvironment(id); return (await this.getAgent(id)) as AgentRecord; } @@ -261,7 +261,7 @@ export class AgentManager { await runCommand("tmux", ["kill-session", "-t", agent.tmuxSession]); } - await this.cleanupDevEnvironment(id); + this.cleanupDevEnvironment(id); await this.pool.query("DELETE FROM agents WHERE id = $1", [id]); @@ -432,7 +432,7 @@ export class AgentManager { const dbNameMatch = content.match(/^DEV_DB_NAME=(.+)$/m); if (dbNameMatch?.[1]) { const containerName = `dispatch-postgres-${dbNameMatch[1]}`; - await runCommand("docker", ["rm", "-f", containerName], { allowedExitCodes: [0, 1] }); + await runCommand("docker", ["rm", "-f", containerName], { allowedExitCodes: [0, 1], timeoutMs: 10_000 }); this.logger.info({ agentId, containerName }, "Cleaned up dev database container"); } await unlink(stateFile).catch(() => {}); From 4e43cdc0b4a29ac995f30259a610ae47781566f1 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 12 Mar 2026 20:12:45 -0600 Subject: [PATCH 4/6] Address PR review feedback - Fix floating promise: add .catch() to fire-and-forget cleanupDevEnvironment calls so errors are logged not swallowed - Add docker compose project name (-p) to dispatch-dev for consistent container namespacing - Use PID+timestamp for e2e run IDs to avoid CI parallel collisions - Add TOCTOU comments to find_free_port in both scripts - Add comment explaining NODE_TLS_REJECT_UNAUTHORIZED scope - Rename DEV_DB_NAME to DEV_CONTAINER_SUFFIX for clarity - Document start/stop aliases in dispatch-dev usage - Remove unrelated .dispatch/config.json change Co-Authored-By: Claude Opus 4.6 --- .dispatch/config.json | 2 +- bin/dispatch-dev | 36 +++++++++++++++++++++--------------- playwright.config.ts | 2 ++ scripts/e2e-isolated.sh | 7 +++++-- src/agents/manager.ts | 10 +++++++--- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.dispatch/config.json b/.dispatch/config.json index ab871b7a..c7a52dc5 100644 --- a/.dispatch/config.json +++ b/.dispatch/config.json @@ -1,5 +1,5 @@ { - "worktreeMode": "ask", + "worktreeMode": "auto", "linear": { "team": "CrumbStream", "teamKey": "CRU", diff --git a/bin/dispatch-dev b/bin/dispatch-dev index 5c5392af..8579e9b0 100755 --- a/bin/dispatch-dev +++ b/bin/dispatch-dev @@ -28,6 +28,10 @@ Commands: logs Show recent output (default: api; use --vite or --db) url Print the dev server URL +Aliases: + start Alias for up + stop Alias for down + Options: --cwd Working directory (default: current directory) --vite Also start the Vite frontend dev server @@ -58,6 +62,8 @@ has_window() { tmux list-windows -t "$session" -F '#{window_name}' 2>/dev/null | grep -qx "$name" } +# Grab a free port from the OS. Small TOCTOU window between probe and actual +# bind, but acceptable for dev tooling — collisions are extremely unlikely. find_free_port() { node -e ' const net = require("net"); @@ -97,7 +103,7 @@ save_state() { DEV_DB_PORT=${DEV_DB_PORT:-} DEV_API_PORT=${DEV_API_PORT:-} DEV_VITE_PORT=${DEV_VITE_PORT:-} -DEV_DB_NAME=${DEV_DB_NAME:-} +DEV_CONTAINER_SUFFIX=${DEV_CONTAINER_SUFFIX:-} DEV_CWD=${DEV_CWD:-} EOF } @@ -127,22 +133,22 @@ cmd_up() { # --- Database --- if [ "$OPT_NO_DB" != "1" ]; then - DEV_DB_NAME="$(db_container_suffix)" + DEV_CONTAINER_SUFFIX="$(db_container_suffix)" DEV_DB_PORT="$(find_free_port)" - local container_name="dispatch-postgres-${DEV_DB_NAME}" + local container_name="dispatch-postgres-${DEV_CONTAINER_SUFFIX}" echo "Starting database: ${container_name} on port ${DEV_DB_PORT}..." # Stop any existing container with this name docker rm -f "$container_name" 2>/dev/null || true - DISPATCH_DB_NAME="$DEV_DB_NAME" DISPATCH_DB_PORT="$DEV_DB_PORT" \ - docker compose -f "${SCRIPT_DIR}/docker-compose.yml" up -d 2>&1 + DISPATCH_DB_NAME="$DEV_CONTAINER_SUFFIX" DISPATCH_DB_PORT="$DEV_DB_PORT" \ + docker compose -f "${SCRIPT_DIR}/docker-compose.yml" -p "dispatch-dev-${DEV_CONTAINER_SUFFIX}" up -d 2>&1 # Wait for healthy local attempts=0 while [ $attempts -lt 30 ]; do - if docker exec "$container_name" pg_isready -U dispatch -d "dispatch_${DEV_DB_NAME}" >/dev/null 2>&1; then + if docker exec "$container_name" pg_isready -U dispatch -d "dispatch_${DEV_CONTAINER_SUFFIX}" >/dev/null 2>&1; then echo "Database ready on port ${DEV_DB_PORT}" break fi @@ -158,7 +164,7 @@ cmd_up() { DEV_CWD="$cwd" save_state echo "" - echo "Database only mode. Set DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_DB_NAME}" + echo "Database only mode. Set DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_CONTAINER_SUFFIX}" return fi @@ -183,7 +189,7 @@ cmd_up() { # Override port and DATABASE_URL to point at our container local db_url_override="" if [ "$OPT_NO_DB" != "1" ]; then - db_url_override="DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_DB_NAME} " + db_url_override="DATABASE_URL=postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_CONTAINER_SUFFIX} " fi local api_cmd="${nvm_init}${env_exports}${db_url_override}DISPATCH_PORT=${DEV_API_PORT} npm run dev" @@ -210,7 +216,7 @@ cmd_up() { echo "" echo "Dev environment ready:" if [ "$OPT_NO_DB" != "1" ]; then - echo " db: postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_DB_NAME}" + echo " db: postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_CONTAINER_SUFFIX}" fi echo " api: http://127.0.0.1:${DEV_API_PORT}" if [ "$OPT_VITE" = "1" ]; then @@ -233,8 +239,8 @@ cmd_down() { done # Stop the DB container - if [ -n "${DEV_DB_NAME:-}" ]; then - local container_name="dispatch-postgres-${DEV_DB_NAME}" + if [ -n "${DEV_CONTAINER_SUFFIX:-}" ]; then + local container_name="dispatch-postgres-${DEV_CONTAINER_SUFFIX}" if docker ps -q -f "name=${container_name}" 2>/dev/null | grep -q .; then docker rm -f "$container_name" 2>/dev/null || true echo "Removed database container: ${container_name}" @@ -254,8 +260,8 @@ cmd_status() { load_state # DB - if [ -n "${DEV_DB_NAME:-}" ]; then - local container_name="dispatch-postgres-${DEV_DB_NAME}" + if [ -n "${DEV_CONTAINER_SUFFIX:-}" ]; then + local container_name="dispatch-postgres-${DEV_CONTAINER_SUFFIX}" if docker ps -q -f "name=${container_name}" 2>/dev/null | grep -q .; then echo "db: running (${container_name}, port ${DEV_DB_PORT:-?})" else @@ -299,8 +305,8 @@ cmd_logs() { elif [ "$OPT_DB_ONLY" = "1" ]; then # Show docker logs for the DB container load_state - if [ -n "${DEV_DB_NAME:-}" ]; then - docker logs --tail 100 "dispatch-postgres-${DEV_DB_NAME}" 2>&1 + if [ -n "${DEV_CONTAINER_SUFFIX:-}" ]; then + docker logs --tail 100 "dispatch-postgres-${DEV_CONTAINER_SUFFIX}" 2>&1 return fi echo "No managed database found." >&2 diff --git a/playwright.config.ts b/playwright.config.ts index 22bf78f2..b9cf3ed5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from "@playwright/test"; +// Allow self-signed certs when running e2e against a TLS-enabled dev server. +// e2e-isolated.sh unsets TLS_CERT so this only fires for manual TLS test runs. if (process.env.TLS_CERT) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } diff --git a/scripts/e2e-isolated.sh b/scripts/e2e-isolated.sh index f12c3abd..88b2c339 100755 --- a/scripts/e2e-isolated.sh +++ b/scripts/e2e-isolated.sh @@ -11,7 +11,9 @@ else exit 1 fi -# Grab actually-free ports from the OS to avoid collisions between concurrent runs +# Grab a free port from the OS. There is a small TOCTOU window between closing +# the probe socket and the actual service binding, but this is acceptable for +# dev/test tooling — collisions are extremely unlikely in practice. find_free_port() { node -e ' const net = require("net"); @@ -23,7 +25,8 @@ find_free_port() { ' } -RUN_ID="e2e-$$" +# Include timestamp for uniqueness across CI parallel containers +RUN_ID="e2e-$$-$(date +%s)" DB_PORT="$(find_free_port)" API_PORT="$(find_free_port)" diff --git a/src/agents/manager.ts b/src/agents/manager.ts index 0e2ce625..6b1010e0 100644 --- a/src/agents/manager.ts +++ b/src/agents/manager.ts @@ -239,7 +239,9 @@ export class AgentManager { throw new AgentError(`Failed to stop agent: ${message}`, 500); } - this.cleanupDevEnvironment(id); + this.cleanupDevEnvironment(id).catch((err) => + this.logger.warn({ err, id }, "Dev environment cleanup failed") + ); return (await this.getAgent(id)) as AgentRecord; } @@ -261,7 +263,9 @@ export class AgentManager { await runCommand("tmux", ["kill-session", "-t", agent.tmuxSession]); } - this.cleanupDevEnvironment(id); + this.cleanupDevEnvironment(id).catch((err) => + this.logger.warn({ err, id }, "Dev environment cleanup failed") + ); await this.pool.query("DELETE FROM agents WHERE id = $1", [id]); @@ -429,7 +433,7 @@ export class AgentManager { const stateFile = `/tmp/dispatch-dev-${agentId}.env`; try { const content = await readFile(stateFile, "utf-8"); - const dbNameMatch = content.match(/^DEV_DB_NAME=(.+)$/m); + const dbNameMatch = content.match(/^DEV_CONTAINER_SUFFIX=(.+)$/m); if (dbNameMatch?.[1]) { const containerName = `dispatch-postgres-${dbNameMatch[1]}`; await runCommand("docker", ["rm", "-f", containerName], { allowedExitCodes: [0, 1], timeoutMs: 10_000 }); From a9e122d53448e0560c6175771de0144a1adf9556 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 12 Mar 2026 20:16:18 -0600 Subject: [PATCH 5/6] Include agent name in tmux session names for easier debugging Session names now follow the pattern dispatch_agt_xxx_task-slug (e.g. dispatch_agt_393f6be6672e_projects) instead of just dispatch_agt_393f6be6672e. Makes it easy to identify agents when debugging rogue tmux sessions with tmux list-sessions. Added agentIdFromSessionName() helper to reliably extract the agent ID from either the old or new session name format. Co-Authored-By: Claude Opus 4.6 --- src/agents/manager.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/agents/manager.ts b/src/agents/manager.ts index 6b1010e0..8f5b2502 100644 --- a/src/agents/manager.ts +++ b/src/agents/manager.ts @@ -101,10 +101,10 @@ export class AgentManager { async createAgent(input: CreateAgentInput): Promise { const cwd = await this.validateWorkingDirectory(input.cwd); const id = this.newAgentId(); - const tmuxSession = this.toSessionName(id); const type: AgentType = input.type ?? "codex"; const codexArgs = input.codexArgs ?? []; const name = input.name?.trim() || `agent-${id.slice(-6)}`; + const tmuxSession = this.toSessionName(id, name); const mediaDir = path.join(this.config.mediaRoot, id); await mkdir(mediaDir, { recursive: true }); @@ -140,7 +140,7 @@ export class AgentManager { async startAgent(id: string): Promise { const agent = await this.getRequiredAgent(id); - const tmuxSession = agent.tmuxSession ?? this.toSessionName(agent.id); + const tmuxSession = agent.tmuxSession ?? this.toSessionName(agent.id, agent.name); const hasSession = await this.tmuxHasSession(tmuxSession); if (hasSession) { @@ -382,7 +382,7 @@ export class AgentManager { if (sessions.length === 0) return; // Extract agent IDs from session names - const agentIds = sessions.map((s) => s.name.replace(/^dispatch_/, "")); + const agentIds = sessions.map((s) => this.agentIdFromSessionName(s.name)); // Query DB for these agent IDs const placeholders = agentIds.map((_, i) => `$${i + 1}`).join(", "); @@ -400,7 +400,7 @@ export class AgentManager { const toKill: string[] = []; for (const session of sessions) { - const agentId = session.name.replace(/^dispatch_/, ""); + const agentId = this.agentIdFromSessionName(session.name); const status = dbAgents.get(agentId); // Agent in terminal state — session is definitely orphaned @@ -482,7 +482,7 @@ export class AgentManager { } private buildAgentCommand(type: AgentType, args: string[], mediaDir: string, sessionName: string): string { - const agentId = sessionName.replace(/^(dispatch|hostess)_/, ""); + const agentId = this.agentIdFromSessionName(sessionName); // Lean startup guidance shared by both agent types. Full behavioral specs live in // AGENTS.md (auto-loaded by Codex) and CLAUDE.md (auto-loaded by Claude Code). const launchGuidance = @@ -622,8 +622,25 @@ export class AgentManager { return `agt_${randomUUID().replaceAll("-", "").slice(0, 12)}`; } - private toSessionName(agentId: string): string { - return `dispatch_${agentId}`; + /** Extract agent ID from a session name like "dispatch_agt_abc123_my-task". */ + private agentIdFromSessionName(sessionName: string): string { + const match = sessionName.match(/(?:dispatch|hostess)_(agt_[a-f0-9]{12})/); + return match?.[1] ?? sessionName.replace(/^(dispatch|hostess)_/, ""); + } + + private toSessionName(agentId: string, agentName?: string): string { + if (!agentName) { + return `dispatch_${agentId}`; + } + // Sanitize: tmux disallows colons and periods in session names. + // Collapse whitespace/special chars to hyphens, truncate to keep it readable. + const slug = agentName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 30); + return `dispatch_${agentId}_${slug}`; } private defaultMediaDir(agentId: string): string { From 68803c50ba0480f6447d69aff15d9f59c09572e2 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 12 Mar 2026 20:22:30 -0600 Subject: [PATCH 6/6] Fix dispatch-dev session lookup for slugged session names dispatch-dev now finds the agent's tmux session by prefix match instead of exact match, since session names now include the agent name slug (e.g. dispatch_agt_xxx_my-task). Co-Authored-By: Claude Opus 4.6 --- bin/dispatch-dev | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/dispatch-dev b/bin/dispatch-dev index 8579e9b0..d6c65899 100755 --- a/bin/dispatch-dev +++ b/bin/dispatch-dev @@ -50,7 +50,16 @@ resolve_session() { echo "DISPATCH_AGENT_ID is not set. Run this inside a Dispatch agent session." >&2 exit 1 fi - echo "dispatch_${agent_id}" + # Session names may include a name slug (e.g. dispatch_agt_xxx_my-task). + # Match by agent ID prefix since we don't know the full session name. + local prefix="dispatch_${agent_id}" + local match + match="$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${prefix}" | head -1)" + if [ -z "$match" ]; then + echo "$prefix" + else + echo "$match" + fi } resolve_agent_id() {