diff --git a/AGENTS.md b/AGENTS.md index 780e9e0c..5da44673 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). 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. ## 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..d6a80862 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). 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. ## 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..d6c65899 --- /dev/null +++ b/bin/dispatch-dev @@ -0,0 +1,376 @@ +#!/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 + +Aliases: + start Alias for up + stop Alias for down + +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 + # 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() { + 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" +} + +# 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"); + 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_CONTAINER_SUFFIX="$(db_container_suffix)" + DEV_DB_PORT="$(find_free_port)" + 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_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_CONTAINER_SUFFIX}" >/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_CONTAINER_SUFFIX}" + 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_CONTAINER_SUFFIX} " + 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_CONTAINER_SUFFIX}" + 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_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}" + 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_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 + 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_CONTAINER_SUFFIX:-}" ]; then + docker logs --tail 100 "dispatch-postgres-${DEV_CONTAINER_SUFFIX}" 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..7cfe1f60 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "check": "tsc --noEmit && npm run check:web", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npx playwright test" + "test:e2e": "bash scripts/e2e-isolated.sh" }, "dependencies": { "@fastify/multipart": "^8.3.1", diff --git a/playwright.config.ts b/playwright.config.ts index 4bf15a93..b9cf3ed5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,19 @@ 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"; +} + 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,13 +25,16 @@ 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, + reuseExistingServer: false, timeout: 60_000, }, }); diff --git a/scripts/e2e-isolated.sh b/scripts/e2e-isolated.sh new file mode 100755 index 00000000..88b2c339 --- /dev/null +++ b/scripts/e2e-isolated.sh @@ -0,0 +1,60 @@ +#!/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 + +# 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"); + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + console.log(srv.address().port); + srv.close(); + }); + ' +} + +# Include timestamp for uniqueness across CI parallel containers +RUN_ID="e2e-$$-$(date +%s)" +DB_PORT="$(find_free_port)" +API_PORT="$(find_free_port)" + +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..8f5b2502 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"; @@ -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) { @@ -239,6 +239,10 @@ export class AgentManager { throw new AgentError(`Failed to stop agent: ${message}`, 500); } + this.cleanupDevEnvironment(id).catch((err) => + this.logger.warn({ err, id }, "Dev environment cleanup failed") + ); + return (await this.getAgent(id)) as AgentRecord; } @@ -259,6 +263,10 @@ export class AgentManager { await runCommand("tmux", ["kill-session", "-t", agent.tmuxSession]); } + 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]); const mediaDir = agent.mediaDir ?? path.join(this.config.mediaRoot, id); @@ -374,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(", "); @@ -392,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 @@ -417,6 +425,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_CONTAINER_SUFFIX=(.+)$/m); + if (dbNameMatch?.[1]) { + const containerName = `dispatch-postgres-${dbNameMatch[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(() => {}); + } catch { + // No state file means no dev environment was created — nothing to clean up. + } + } + private async startTmuxSession( sessionName: string, cwd: string, @@ -454,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 = @@ -594,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 {