From 4020b5222f28673d7631450ecbcb12d5d93e7afc Mon Sep 17 00:00:00 2001 From: Alexander Payne Date: Sun, 22 Feb 2026 16:21:32 -0500 Subject: [PATCH] feat: add Docker-based swarm agent containerization Add infrastructure for running swarm agents as isolated Docker containers with HTTP-based coordination, startup recovery, and enhanced dashboard UI for agent management. - Dockerfile and docker-compose.yml for multi-service orchestration - DockerAgentRunner for programmatic container lifecycle management - Internal HTTP API for container agents to poll tasks and submit bids - Startup recovery system to reconcile orphaned tasks and stale agents - Enhanced UI partials for agent panels, chat, and task assignment - Timmy docker entry point with heartbeat and task polling - New Makefile targets for Docker workflows - Tests for swarm recovery Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 37 +++ AGENTS.md | 254 +++++++++++++----- Dockerfile | 58 ++++ Makefile | 37 ++- docker-compose.yml | 109 ++++++++ src/dashboard/app.py | 20 ++ src/dashboard/routes/agents.py | 31 ++- src/dashboard/routes/marketplace.py | 6 +- src/dashboard/routes/swarm.py | 143 +++++++++- src/dashboard/routes/swarm_internal.py | 115 ++++++++ src/dashboard/templates/base.html | 4 +- src/dashboard/templates/index.html | 78 ++---- .../templates/partials/agent_chat_msg.html | 27 ++ .../templates/partials/agent_panel.html | 82 ++++++ .../partials/swarm_agents_sidebar.html | 50 ++++ .../templates/partials/task_assign_panel.html | 60 +++++ .../templates/partials/task_result.html | 28 ++ .../templates/partials/timmy_panel.html | 58 ++++ src/swarm/agent_runner.py | 137 ++++++++-- src/swarm/coordinator.py | 2 + src/swarm/docker_runner.py | 187 +++++++++++++ src/swarm/recovery.py | 90 +++++++ src/timmy/docker_agent.py | 139 ++++++++++ static/bg.svg | 139 ++++++++++ static/style.css | 77 +++--- tests/conftest.py | 17 ++ tests/test_dashboard.py | 3 +- tests/test_mobile_scenarios.py | 19 +- tests/test_swarm_recovery.py | 179 ++++++++++++ 29 files changed, 1984 insertions(+), 202 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/dashboard/routes/swarm_internal.py create mode 100644 src/dashboard/templates/partials/agent_chat_msg.html create mode 100644 src/dashboard/templates/partials/agent_panel.html create mode 100644 src/dashboard/templates/partials/swarm_agents_sidebar.html create mode 100644 src/dashboard/templates/partials/task_assign_panel.html create mode 100644 src/dashboard/templates/partials/task_result.html create mode 100644 src/dashboard/templates/partials/timmy_panel.html create mode 100644 src/swarm/docker_runner.py create mode 100644 src/swarm/recovery.py create mode 100644 src/timmy/docker_agent.py create mode 100644 static/bg.svg create mode 100644 tests/test_swarm_recovery.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e7b8b11b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# ── Python ─────────────────────────────────────────────────────────────────── +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +dist/ +build/ +.pytest_cache/ +htmlcov/ +.coverage +coverage.xml + +# ── Data (mounted as volume, not baked in) ─────────────────────────────────── +data/ +*.db + +# ── Secrets / config ───────────────────────────────────────────────────────── +.env +.env.* +*.key +*.pem + +# ── Git ─────────────────────────────────────────────────────────────────────── +.git/ +.gitignore + +# ── Tests (not needed in production image) ─────────────────────────────────── +tests/ + +# ── Docs ───────────────────────────────────────────────────────────────────── +docs/ +*.md + +# ── macOS ───────────────────────────────────────────────────────────────────── +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 70faec5f..7acbbe8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md — Timmy Time Development Standards for AI Agents -This file is the authoritative reference for any AI agent (Claude, Kimi, Manus, -or future tools) contributing to this repository. Read it first. Every time. +This file is the authoritative reference for any AI agent contributing to +this repository. Read it first. Every time. --- @@ -10,15 +10,16 @@ or future tools) contributing to this repository. Read it first. Every time. **Timmy Time** is a local-first, sovereign AI agent system. No cloud. No telemetry. Bitcoin Lightning economics baked in. -| Thing | Value | -|------------------|----------------------------------------| -| Language | Python 3.11+ | -| Web framework | FastAPI + Jinja2 + HTMX | -| Agent framework | Agno (wraps Ollama or AirLLM) | -| Persistence | SQLite (`timmy.db`, `data/swarm.db`) | -| Tests | pytest — 228 passing, **must stay green** | -| Entry points | `timmy`, `timmy-serve`, `self-tdd` | -| Config | pydantic-settings, reads `.env` | +| Thing | Value | +|------------------|----------------------------------------------------| +| Language | Python 3.11+ | +| Web framework | FastAPI + Jinja2 + HTMX | +| Agent framework | Agno (wraps Ollama or AirLLM) | +| Persistence | SQLite (`timmy.db`, `data/swarm.db`) | +| Tests | pytest — must stay green | +| Entry points | `timmy`, `timmy-serve`, `self-tdd` | +| Config | pydantic-settings, reads `.env` | +| Containers | Docker — each agent can run as an isolated service | ``` src/ @@ -28,9 +29,11 @@ src/ app.py store.py # In-memory MessageLog singleton routes/ # agents, health, swarm, swarm_ws, marketplace, - │ # mobile, mobile_test, voice, voice_enhanced + │ # mobile, mobile_test, voice, voice_enhanced, + │ # swarm_internal (HTTP API for Docker agents) templates/ # base.html + page templates + partials/ swarm/ # Multi-agent coordinator, registry, bidder, tasks, comms + docker_runner.py # Spawn agents as Docker containers timmy_serve/ # L402 Lightning proxy, payment handler, TTS, CLI voice/ # NLU intent detection (regex-based, no cloud) websocket/ # WebSocket manager (ws_manager singleton) @@ -38,68 +41,68 @@ src/ shortcuts/ # Siri Shortcuts API endpoints self_tdd/ # Continuous test watchdog tests/ # One test_*.py per module, all mocked -static/style.css # Dark mission-control theme (JetBrains Mono) -docs/ # GitHub Pages site (docs/index.html) +static/ # style.css + bg.svg (arcane theme) +docs/ # GitHub Pages site ``` --- ## 2. Non-Negotiable Rules -1. **Tests must stay green.** Run `make test` before committing. If you break - tests, fix them before you do anything else. -2. **No cloud dependencies.** All computation must run on localhost. +1. **Tests must stay green.** Run `make test` before committing. +2. **No cloud dependencies.** All AI computation runs on localhost. 3. **No new top-level files without purpose.** Don't litter the root directory. -4. **Follow existing patterns** — singletons (`message_log`, `notifier`, - `ws_manager`, `coordinator`), graceful degradation (try/except → fallback), - pydantic-settings config. -5. **Security defaults:** Never hard-code secrets. Warn at startup when defaults - are in use (see `l402_proxy.py` and `payment_handler.py` for the pattern). -6. **XSS prevention:** Never use `innerHTML` with untrusted content. Use - `textContent` or `innerText` for any user-controlled string in JS. +4. **Follow existing patterns** — singletons, graceful degradation, pydantic-settings config. +5. **Security defaults:** Never hard-code secrets. Warn at startup when defaults are in use. +6. **XSS prevention:** Never use `innerHTML` with untrusted content. --- -## 3. Per-Agent Assignments +## 3. Agent Roster -### Claude (Anthropic) -**Strengths:** Architecture, scaffolding, iterative refinement, testing, docs, breadth. +Agents are divided into two tiers: **Builders** generate code and features; +**Reviewers** provide quality gates, feedback, and hardening. The Local agent +is the primary workhorse — use it as much as possible to minimise cost. + +--- + +### 🏗️ BUILD TIER + +--- + +### Local — Ollama (primary workhorse) +**Model:** Any — `qwen2.5-coder`, `deepseek-coder-v2`, `codellama`, or whatever +is loaded in Ollama. The owner decides the model; this agent is unrestricted. +**Cost:** Free. Runs on the host machine. **Best for:** -- Adding new subsystems from scratch -- Refactoring / code-quality passes -- Writing or fixing tests -- Updating documentation (README, AGENTS.md, inline comments) -- CI/CD and tooling -- Debugging tricky async or import issues +- Everything. This is the default agent for all coding tasks. +- Iterative development, fast feedback loops, bulk generation +- Running as a Docker swarm worker — scales horizontally at zero marginal cost +- Experimenting with new models without changing any other code **Conventions to follow:** -- Prefer editing existing files over creating new ones -- Keep route files thin — business logic lives in the module, not the route -- Use `from config import settings` for all env-var access -- New routes go in `src/dashboard/routes/`, registered in `app.py` -- New templates extend `base.html` -- Always add a corresponding `tests/test_.py` +- Communicate with the coordinator over HTTP (`COORDINATOR_URL` env var) +- Register capabilities honestly so the auction system routes tasks well +- Write tests for anything non-trivial -**Avoid:** -- Large one-shot feature dumps (that's Kimi's lane) -- Touching `src/swarm/coordinator.py` for security work (that's Manus's lane) -- Committing with `--no-verify` +**No restrictions.** If a model can do it, do it. --- ### Kimi (Moonshot AI) -**Strengths:** High-volume feature generation, rapid expansion, large context. +**Model:** Moonshot large-context models. +**Cost:** Paid API. **Best for:** -- Big feature drops (new pages, new subsystems, new agent personas) -- Implementing the roadmap items listed below +- Large context feature drops (new pages, new subsystems, new agent personas) +- Implementing roadmap items that require reading many files at once - Generating boilerplate for new agents (Echo, Mace, Helm, Seer, Forge, Quill) **Conventions to follow:** - Deliver working code with accompanying tests (even if minimal) -- Match the dark Mission Control CSS theme — extend `static/style.css` -- New agents should follow the `SwarmNode` + `Registry` pattern in `src/swarm/` +- Match the arcane CSS theme — extend `static/style.css` +- New agents follow the `SwarmNode` + `Registry` + Docker pattern - Lightning-gated endpoints follow the L402 pattern in `src/timmy_serve/l402_proxy.py` **Avoid:** @@ -109,6 +112,78 @@ docs/ # GitHub Pages site (docs/index.html) --- +### DeepSeek (DeepSeek API) +**Model:** `deepseek-chat` (V3) or `deepseek-reasoner` (R1). +**Cost:** Near-free (~$0.14/M tokens). + +**Best for:** +- Second-opinion feature generation when Kimi is busy or context is smaller +- Large refactors with reasoning traces (use R1 for hard problems) +- Code review passes before merging Kimi PRs +- Anything that doesn't need a frontier model but benefits from strong reasoning + +**Conventions to follow:** +- Same conventions as Kimi +- Prefer V3 for straightforward tasks; R1 for anything requiring multi-step logic +- Submit PRs for review by Claude before merging + +**Avoid:** +- Bypassing the review tier for security-sensitive modules +- Touching `src/swarm/coordinator.py` without Claude review + +--- + +### 🔍 REVIEW TIER + +--- + +### Claude (Anthropic) +**Model:** Claude Sonnet. +**Cost:** Paid API. + +**Best for:** +- Architecture decisions and code-quality review +- Writing and fixing tests; keeping coverage green +- Updating documentation (README, AGENTS.md, inline comments) +- CI/CD, tooling, Docker infrastructure +- Debugging tricky async or import issues +- Reviewing PRs from Local, Kimi, and DeepSeek before merge + +**Conventions to follow:** +- Prefer editing existing files over creating new ones +- Keep route files thin — business logic lives in the module, not the route +- Use `from config import settings` for all env-var access +- New routes go in `src/dashboard/routes/`, registered in `app.py` +- Always add a corresponding `tests/test_.py` + +**Avoid:** +- Large one-shot feature dumps (use Local or Kimi) +- Touching `src/swarm/coordinator.py` for security work (that's Manus's lane) + +--- + +### Gemini (Google) +**Model:** Gemini 2.0 Flash (free tier) or Pro. +**Cost:** Free tier generous; upgrade only if needed. + +**Best for:** +- Documentation, README updates, inline docstrings +- Frontend polish — HTML templates, CSS, accessibility review +- Boilerplate generation (test stubs, config files, GitHub Actions) +- Summarising large diffs for human review + +**Conventions to follow:** +- Submit changes as PRs; always include a plain-English summary of what changed +- For CSS changes, test at mobile breakpoint (≤768px) before submitting +- Never modify Python business logic without Claude review + +**Avoid:** +- Security-sensitive modules (that's Manus's lane) +- Changing auction or payment logic +- Large Python refactors + +--- + ### Manus AI **Strengths:** Precision security work, targeted bug fixes, coverage gap analysis. @@ -126,21 +201,58 @@ docs/ # GitHub Pages site (docs/index.html) **Avoid:** - Large-scale refactors (that's Claude's lane) -- New feature work (that's Kimi's lane) +- New feature work (use Local or Kimi) - Changing agent personas or prompt content --- -## 4. Architecture Patterns +## 4. Docker — Running Agents as Containers + +Each agent can run as an isolated Docker container. Containers share the +`data/` volume for SQLite and communicate with the coordinator over HTTP. + +```bash +make docker-build # build the image +make docker-up # start dashboard + deps +make docker-agent # spawn one agent worker (LOCAL model) +make docker-down # stop everything +make docker-logs # tail all service logs +``` + +### How container agents communicate + +Container agents cannot use the in-memory `SwarmComms` channel. Instead they +poll the coordinator's internal HTTP API: + +``` +GET /internal/tasks → list tasks open for bidding +POST /internal/bids → submit a bid +``` + +Set `COORDINATOR_URL=http://dashboard:8000` in the container environment +(docker-compose sets this automatically). + +### Spawning a container agent from Python + +```python +from swarm.docker_runner import DockerAgentRunner + +runner = DockerAgentRunner(coordinator_url="http://dashboard:8000") +info = runner.spawn("Echo", image="timmy-time:latest") +runner.stop(info["container_id"]) +``` + +--- + +## 5. Architecture Patterns ### Singletons (module-level instances) -These are shared state — import them, don't recreate them: ```python -from dashboard.store import message_log # MessageLog -from notifications.push import notifier # PushNotifier -from websocket.handler import ws_manager # WebSocketManager -from timmy_serve.payment_handler import payment_handler # PaymentHandler -from swarm.coordinator import coordinator # SwarmCoordinator +from dashboard.store import message_log +from notifications.push import notifier +from websocket.handler import ws_manager +from timmy_serve.payment_handler import payment_handler +from swarm.coordinator import coordinator ``` ### Config access @@ -150,8 +262,6 @@ url = settings.ollama_url # never os.environ.get() directly in route files ``` ### HTMX pattern -Server renders HTML fragments. Routes return `TemplateResponse` with a partial -template. JS is minimal — no React, no Vue. ```python return templates.TemplateResponse( "partials/chat_message.html", @@ -175,45 +285,44 @@ except Exception: --- -## 5. Running Locally +## 6. Running Locally ```bash make install # create venv + install dev deps make test # run full test suite make dev # start dashboard (http://localhost:8000) -make watch # self-TDD watchdog (background, 60s interval) +make watch # self-TDD watchdog (60s poll) make test-cov # coverage report ``` -Or manually: +Or with Docker: ```bash -python3 -m venv .venv && source .venv/bin/activate -pip install -e ".[dev]" -pytest # all 228 tests -uvicorn dashboard.app:app --reload --host 0.0.0.0 --port 8000 +make docker-build # build image +make docker-up # start dashboard +make docker-agent # add a Local agent worker ``` --- -## 6. Roadmap (v2 → v3) - -These are unbuilt items — claim one per PR, coordinate via Issues: +## 7. Roadmap (v2 → v3) **v2.0.0 — Exodus (in progress)** -- [ ] Implement Echo, Mace, Helm, Seer, Forge, Quill agent personas as Agno agents +- [x] Persistent swarm state across restarts +- [x] Docker infrastructure for agent containers +- [ ] Implement Echo, Mace, Helm, Seer, Forge, Quill persona agents (Dockerised) - [ ] Real LND gRPC backend for `PaymentHandler` (replace mock) - [ ] MCP tool integration for Timmy -- [ ] Marketplace frontend — wire up the existing `/marketplace` route to real data -- [ ] Persistent swarm state across restarts (currently in-memory) +- [ ] Marketplace frontend — wire `/marketplace` route to real data **v3.0.0 — Revelation (planned)** - [ ] Bitcoin Lightning treasury (agent earns and spends sats autonomously) - [ ] Single `.app` bundle for macOS (no Python install required) - [ ] Federation — multiple Timmy instances discover and bid on each other's tasks +- [ ] Redis pub/sub replacing SQLite polling for high-throughput swarms --- -## 7. File Conventions +## 8. File Conventions | Pattern | Convention | |---------|-----------| @@ -224,3 +333,4 @@ These are unbuilt items — claim one per PR, coordinate via Issues: | New test file | `tests/test_.py` | | Secrets | Read via `os.environ.get("VAR", "default")` + startup warning if default | | DB files | `.db` files go in project root or `data/` — never in `src/` | +| Docker | One service per agent type in `docker-compose.yml` | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..1a61121a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# ── Timmy Time — agent image ──────────────────────────────────────────────── +# +# Serves two purposes: +# 1. `make docker-up` → runs the FastAPI dashboard (default CMD) +# 2. `make docker-agent` → runs a swarm agent worker (override CMD) +# +# Build: docker build -t timmy-time:latest . +# Dash: docker run -p 8000:8000 -v $(pwd)/data:/app/data timmy-time:latest +# Agent: docker run -e COORDINATOR_URL=http://dashboard:8000 \ +# -e AGENT_NAME=Worker-1 \ +# timmy-time:latest \ +# python -m swarm.agent_runner --agent-id w1 --name Worker-1 + +FROM python:3.12-slim + +# ── System deps ────────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# ── Python deps (install before copying src for layer caching) ─────────────── +COPY pyproject.toml . + +# Install production deps only (no dev/test extras in the image) +RUN pip install --no-cache-dir \ + "fastapi>=0.115.0" \ + "uvicorn[standard]>=0.32.0" \ + "jinja2>=3.1.0" \ + "httpx>=0.27.0" \ + "python-multipart>=0.0.12" \ + "aiofiles>=24.0.0" \ + "typer>=0.12.0" \ + "rich>=13.0.0" \ + "pydantic-settings>=2.0.0" \ + "websockets>=12.0" \ + "agno[sqlite]>=1.4.0" \ + "ollama>=0.3.0" \ + "openai>=1.0.0" \ + "python-telegram-bot>=21.0" + +# ── Application source ─────────────────────────────────────────────────────── +COPY src/ ./src/ +COPY static/ ./static/ + +# Create data directory (mounted as a volume in production) +RUN mkdir -p /app/data + +# ── Environment ────────────────────────────────────────────────────────────── +ENV PYTHONPATH=/app/src +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +EXPOSE 8000 + +# ── Default: run the dashboard ─────────────────────────────────────────────── +CMD ["uvicorn", "dashboard.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 8cbe2ae9..d564bfbd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PHONY: install install-bigbrain dev test test-cov watch lint clean help +.PHONY: install install-bigbrain dev test test-cov watch lint clean help \ + docker-build docker-up docker-down docker-agent docker-logs docker-shell VENV := .venv PYTHON := $(VENV)/bin/python @@ -65,6 +66,33 @@ lint: # ── Housekeeping ────────────────────────────────────────────────────────────── +# ── Docker ──────────────────────────────────────────────────────────────────── + +docker-build: + docker build -t timmy-time:latest . + +docker-up: + mkdir -p data + docker compose up -d dashboard + +docker-down: + docker compose down + +# Spawn one agent worker connected to the running dashboard. +# Override name/capabilities: make docker-agent AGENT_NAME=Echo AGENT_CAPABILITIES=summarise +docker-agent: + AGENT_NAME=$${AGENT_NAME:-Worker} \ + AGENT_CAPABILITIES=$${AGENT_CAPABILITIES:-general} \ + docker compose --profile agents up -d --scale agent=1 agent + +docker-logs: + docker compose logs -f + +docker-shell: + docker compose exec dashboard bash + +# ── Housekeeping ────────────────────────────────────────────────────────────── + clean: find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true @@ -83,3 +111,10 @@ help: @echo " make lint run ruff or flake8" @echo " make clean remove build artefacts and caches" @echo "" + @echo " make docker-build build the timmy-time:latest image" + @echo " make docker-up start dashboard container" + @echo " make docker-agent add one agent worker (AGENT_NAME=Echo)" + @echo " make docker-down stop all containers" + @echo " make docker-logs tail container logs" + @echo " make docker-shell open a bash shell in the dashboard container" + @echo "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6fc1e1bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +# ── Timmy Time — docker-compose ───────────────────────────────────────────── +# +# Services +# dashboard FastAPI app + swarm coordinator (always on) +# agent Swarm worker template — scale with: +# docker compose up --scale agent=N --profile agents +# +# Volumes +# timmy-data Shared SQLite (data/swarm.db + data/timmy.db) +# +# Usage +# make docker-build build the image +# make docker-up start dashboard only +# make docker-agent add one agent worker +# make docker-down stop everything +# make docker-logs tail logs + +version: "3.9" + +services: + + # ── Dashboard (coordinator + FastAPI) ────────────────────────────────────── + dashboard: + build: . + image: timmy-time:latest + container_name: timmy-dashboard + ports: + - "8000:8000" + volumes: + - timmy-data:/app/data + - ./src:/app/src # live-reload: source changes reflect immediately + - ./static:/app/static # live-reload: CSS/asset changes reflect immediately + environment: + DEBUG: "true" + # Point to host Ollama (Mac default). Override in .env if different. + OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" + extra_hosts: + - "host.docker.internal:host-gateway" # Linux compatibility + networks: + - swarm-net + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # ── Timmy — sovereign AI agent (separate container) ─────────────────────── + timmy: + build: . + image: timmy-time:latest + container_name: timmy-agent + volumes: + - timmy-data:/app/data + - ./src:/app/src + environment: + COORDINATOR_URL: "http://dashboard:8000" + OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" + TIMMY_AGENT_ID: "timmy" + extra_hosts: + - "host.docker.internal:host-gateway" + command: ["python", "-m", "timmy.docker_agent"] + networks: + - swarm-net + depends_on: + dashboard: + condition: service_healthy + restart: unless-stopped + + # ── Agent worker template ─────────────────────────────────────────────────── + # Scale horizontally: docker compose up --scale agent=4 --profile agents + # Each container gets a unique AGENT_ID via the replica index. + agent: + build: . + image: timmy-time:latest + profiles: + - agents + volumes: + - timmy-data:/app/data + - ./src:/app/src + environment: + COORDINATOR_URL: "http://dashboard:8000" + OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" + AGENT_NAME: "${AGENT_NAME:-Worker}" + AGENT_CAPABILITIES: "${AGENT_CAPABILITIES:-general}" + extra_hosts: + - "host.docker.internal:host-gateway" + command: ["sh", "-c", "python -m swarm.agent_runner --agent-id agent-$(hostname) --name $${AGENT_NAME:-Worker}"] + networks: + - swarm-net + depends_on: + dashboard: + condition: service_healthy + restart: unless-stopped + +# ── Shared volume ───────────────────────────────────────────────────────────── +volumes: + timmy-data: + driver: local + driver_opts: + type: none + o: bind + device: "${PWD}/data" + +# ── Internal network ────────────────────────────────────────────────────────── +networks: + swarm-net: + driver: bridge diff --git a/src/dashboard/app.py b/src/dashboard/app.py index c87f6e56..9c336130 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -20,6 +20,7 @@ from dashboard.routes.mobile import router as mobile_router from dashboard.routes.swarm_ws import router as swarm_ws_router from dashboard.routes.briefing import router as briefing_router from dashboard.routes.telegram import router as telegram_router +from dashboard.routes.swarm_internal import router as swarm_internal_router logging.basicConfig( level=logging.INFO, @@ -64,6 +65,24 @@ async def _briefing_scheduler() -> None: async def lifespan(app: FastAPI): task = asyncio.create_task(_briefing_scheduler()) + # Register Timmy in the swarm registry so it shows up alongside other agents + from swarm import registry as swarm_registry + swarm_registry.register( + name="Timmy", + capabilities="chat,reasoning,research,planning", + agent_id="timmy", + ) + + # Log swarm recovery summary (reconciliation ran during coordinator init) + from swarm.coordinator import coordinator as swarm_coordinator + rec = swarm_coordinator._recovery_summary + if rec["tasks_failed"] or rec["agents_offlined"]: + logger.info( + "Swarm recovery on startup: %d task(s) → FAILED, %d agent(s) → offline", + rec["tasks_failed"], + rec["agents_offlined"], + ) + # Auto-start Telegram bot if a token is configured from telegram_bot.bot import telegram_bot await telegram_bot.start() @@ -101,6 +120,7 @@ app.include_router(mobile_router) app.include_router(swarm_ws_router) app.include_router(briefing_router) app.include_router(telegram_router) +app.include_router(swarm_internal_router) @app.get("/", response_class=HTMLResponse) diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py index 14cc25e4..6a147352 100644 --- a/src/dashboard/routes/agents.py +++ b/src/dashboard/routes/agents.py @@ -11,21 +11,42 @@ from dashboard.store import message_log router = APIRouter(prefix="/agents", tags=["agents"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) -AGENT_REGISTRY = { +# Static metadata for known agents — enriched onto live registry entries. +_AGENT_METADATA: dict[str, dict] = { "timmy": { - "id": "timmy", - "name": "Timmy", "type": "sovereign", "model": "llama3.2", "backend": "ollama", "version": "1.0.0", - } + }, } @router.get("") async def list_agents(): - return {"agents": list(AGENT_REGISTRY.values())} + """Return all registered agents with live status from the swarm registry.""" + from swarm import registry as swarm_registry + agents = swarm_registry.list_agents() + return { + "agents": [ + { + "id": a.id, + "name": a.name, + "status": a.status, + "capabilities": a.capabilities, + **_AGENT_METADATA.get(a.id, {}), + } + for a in agents + ] + } + + +@router.get("/timmy/panel", response_class=HTMLResponse) +async def timmy_panel(request: Request): + """Timmy chat panel — for HTMX main-panel swaps.""" + from swarm import registry as swarm_registry + agent = swarm_registry.get_agent("timmy") + return templates.TemplateResponse(request, "partials/timmy_panel.html", {"agent": agent}) @router.get("/timmy/history", response_class=HTMLResponse) diff --git a/src/dashboard/routes/marketplace.py b/src/dashboard/routes/marketplace.py index 662be6fb..b5eb2412 100644 --- a/src/dashboard/routes/marketplace.py +++ b/src/dashboard/routes/marketplace.py @@ -73,7 +73,9 @@ def _build_enriched_catalog() -> list[dict]: reg = by_name.get(e["name"].lower()) if reg is not None: - e["status"] = reg.status # idle | busy | offline + # Timmy is always "active" in the marketplace — it's the sovereign + # agent, not just a task worker. Registry idle/busy is internal state. + e["status"] = "active" if e["id"] == "timmy" else reg.status agent_stats = all_stats.get(reg.id, {}) e["tasks_completed"] = agent_stats.get("tasks_won", 0) e["total_earned"] = agent_stats.get("total_earned", 0) @@ -97,9 +99,9 @@ async def marketplace_ui(request: Request): active = [a for a in agents if a["status"] in ("idle", "busy", "active")] planned = [a for a in agents if a["status"] == "planned"] return templates.TemplateResponse( + request, "marketplace.html", { - "request": request, "page_title": "Agent Marketplace", "agents": agents, "active_count": len(active), diff --git a/src/dashboard/routes/swarm.py b/src/dashboard/routes/swarm.py index a9809fc3..b03d04fb 100644 --- a/src/dashboard/routes/swarm.py +++ b/src/dashboard/routes/swarm.py @@ -4,15 +4,17 @@ Provides REST endpoints for managing the swarm: listing agents, spawning sub-agents, posting tasks, and viewing auction results. """ +from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fastapi import APIRouter, Form, Request +from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from swarm import registry from swarm.coordinator import coordinator -from swarm.tasks import TaskStatus +from swarm.tasks import TaskStatus, update_task router = APIRouter(prefix="/swarm", tags=["swarm"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) @@ -28,8 +30,7 @@ async def swarm_status(): async def swarm_live_page(request: Request): """Render the live swarm dashboard page.""" return templates.TemplateResponse( - "swarm_live.html", - {"request": request, "page_title": "Swarm Live"}, + request, "swarm_live.html", {"page_title": "Swarm Live"} ) @@ -127,3 +128,137 @@ async def get_task(task_id: str): "created_at": task.created_at, "completed_at": task.completed_at, } + + +@router.post("/tasks/{task_id}/complete") +async def complete_task(task_id: str, result: str = Form(...)): + """Mark a task completed — called by agent containers.""" + task = coordinator.complete_task(task_id, result) + if task is None: + raise HTTPException(404, "Task not found") + return {"task_id": task_id, "status": task.status.value} + + +# ── UI endpoints (return HTML partials for HTMX) ───────────────────────────── + +@router.get("/agents/sidebar", response_class=HTMLResponse) +async def agents_sidebar(request: Request): + """Sidebar partial: all registered agents.""" + agents = coordinator.list_swarm_agents() + return templates.TemplateResponse( + request, "partials/swarm_agents_sidebar.html", {"agents": agents} + ) + + +@router.get("/agents/{agent_id}/panel", response_class=HTMLResponse) +async def agent_panel(agent_id: str, request: Request): + """Main-panel partial: agent detail + chat + task history.""" + agent = registry.get_agent(agent_id) + if agent is None: + raise HTTPException(404, "Agent not found") + all_tasks = coordinator.list_tasks() + agent_tasks = [t for t in all_tasks if t.assigned_agent == agent_id][-10:] + return templates.TemplateResponse( + request, + "partials/agent_panel.html", + {"agent": agent, "tasks": agent_tasks}, + ) + + +@router.post("/agents/{agent_id}/message", response_class=HTMLResponse) +async def message_agent(agent_id: str, request: Request, message: str = Form(...)): + """Send a direct message to an agent (creates + assigns a task).""" + agent = registry.get_agent(agent_id) + if agent is None: + raise HTTPException(404, "Agent not found") + + timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S") + + # Timmy: route through his AI backend + if agent_id == "timmy": + result_text = error_text = None + try: + from timmy.agent import create_timmy + run = create_timmy().run(message, stream=False) + result_text = run.content if hasattr(run, "content") else str(run) + except Exception as exc: + error_text = f"Timmy is offline: {exc}" + return templates.TemplateResponse( + request, + "partials/agent_chat_msg.html", + { + "message": message, + "agent": agent, + "response": result_text, + "error": error_text, + "timestamp": timestamp, + "task_id": None, + }, + ) + + # Other agents: create a task and assign directly + task = coordinator.post_task(message) + coordinator.auctions.open_auction(task.id) + coordinator.auctions.submit_bid(task.id, agent_id, 1) + coordinator.auctions.close_auction(task.id) + update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id) + registry.update_status(agent_id, "busy") + + return templates.TemplateResponse( + request, + "partials/agent_chat_msg.html", + { + "message": message, + "agent": agent, + "response": None, + "error": None, + "timestamp": timestamp, + "task_id": task.id, + }, + ) + + +@router.get("/tasks/panel", response_class=HTMLResponse) +async def task_create_panel(request: Request, agent_id: Optional[str] = None): + """Task creation panel, optionally pre-selecting an agent.""" + agents = coordinator.list_swarm_agents() + return templates.TemplateResponse( + request, + "partials/task_assign_panel.html", + {"agents": agents, "preselected_agent_id": agent_id}, + ) + + +@router.post("/tasks/direct", response_class=HTMLResponse) +async def direct_assign_task( + request: Request, + description: str = Form(...), + agent_id: Optional[str] = Form(None), +): + """Create a task: assign directly if agent_id given, else open auction.""" + timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S") + + if agent_id: + agent = registry.get_agent(agent_id) + task = coordinator.post_task(description) + coordinator.auctions.open_auction(task.id) + coordinator.auctions.submit_bid(task.id, agent_id, 1) + coordinator.auctions.close_auction(task.id) + update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id) + registry.update_status(agent_id, "busy") + agent_name = agent.name if agent else agent_id + else: + task = coordinator.post_task(description) + winner = await coordinator.run_auction_and_assign(task.id) + task = coordinator.get_task(task.id) + agent_name = winner.agent_id if winner else "unassigned" + + return templates.TemplateResponse( + request, + "partials/task_result.html", + { + "task": task, + "agent_name": agent_name, + "timestamp": timestamp, + }, + ) diff --git a/src/dashboard/routes/swarm_internal.py b/src/dashboard/routes/swarm_internal.py new file mode 100644 index 00000000..a079913b --- /dev/null +++ b/src/dashboard/routes/swarm_internal.py @@ -0,0 +1,115 @@ +"""Internal swarm HTTP API — for Docker container agents. + +Container agents can't use the in-memory SwarmComms channel, so they poll +these lightweight endpoints to participate in the auction system. + +Routes +------ +GET /internal/tasks + Returns all tasks currently in BIDDING status — the set an agent + can submit bids for. + +POST /internal/bids + Accepts a bid from a container agent and feeds it into the in-memory + AuctionManager. The coordinator then closes auctions and assigns + winners exactly as it does for in-process agents. + +These endpoints are intentionally unauthenticated because they are only +reachable inside the Docker swarm-net bridge network. Do not expose them +through a reverse-proxy to the public internet. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from swarm.coordinator import coordinator +from swarm.tasks import TaskStatus + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +# ── Request / response models ───────────────────────────────────────────────── + +class BidRequest(BaseModel): + task_id: str + agent_id: str + bid_sats: int + capabilities: Optional[str] = "" + + +class BidResponse(BaseModel): + accepted: bool + task_id: str + agent_id: str + message: str + + +class TaskSummary(BaseModel): + task_id: str + description: str + status: str + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@router.get("/tasks", response_model=list[TaskSummary]) +def list_biddable_tasks(): + """Return all tasks currently open for bidding. + + Container agents should poll this endpoint and submit bids for any + tasks they are capable of handling. + """ + tasks = coordinator.list_tasks(status=TaskStatus.BIDDING) + return [ + TaskSummary( + task_id=t.id, + description=t.description, + status=t.status.value, + ) + for t in tasks + ] + + +@router.post("/bids", response_model=BidResponse) +def submit_bid(bid: BidRequest): + """Accept a bid from a container agent. + + The bid is injected directly into the in-memory AuctionManager. + If no auction is open for the task (e.g. it already closed), the + bid is rejected gracefully — the agent should just move on. + """ + if bid.bid_sats <= 0: + raise HTTPException(status_code=422, detail="bid_sats must be > 0") + + accepted = coordinator.auctions.submit_bid( + task_id=bid.task_id, + agent_id=bid.agent_id, + bid_sats=bid.bid_sats, + ) + + if accepted: + # Persist bid in stats table for marketplace analytics + from swarm import stats as swarm_stats + swarm_stats.record_bid(bid.task_id, bid.agent_id, bid.bid_sats, won=False) + logger.info( + "Docker agent %s bid %d sats on task %s", + bid.agent_id, bid.bid_sats, bid.task_id, + ) + return BidResponse( + accepted=True, + task_id=bid.task_id, + agent_id=bid.agent_id, + message="Bid accepted.", + ) + + return BidResponse( + accepted=False, + task_id=bid.task_id, + agent_id=bid.agent_id, + message="No open auction for this task — it may have already closed.", + ) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index c954297d..0bbd228b 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -5,13 +5,13 @@ - + {% block title %}Timmy Time — Mission Control{% endblock %} - + diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html index bb448a4f..a51c3b2e 100644 --- a/src/dashboard/templates/index.html +++ b/src/dashboard/templates/index.html @@ -8,22 +8,15 @@
- -
+ +
// AGENTS
-
-
- - TIMMY -
-
- TYPE sovereign
- MODEL llama3.2
- BACKEND ollama
- VERSION 1.0.0 -
-
+
LOADING...
@@ -43,49 +36,13 @@
- -
-
-
- // TIMMY INTERFACE - -
- -
- - -
+ +
@@ -94,9 +51,12 @@ {% endblock %} diff --git a/src/dashboard/templates/partials/agent_chat_msg.html b/src/dashboard/templates/partials/agent_chat_msg.html new file mode 100644 index 00000000..9d05a120 --- /dev/null +++ b/src/dashboard/templates/partials/agent_chat_msg.html @@ -0,0 +1,27 @@ +
+
YOU // {{ timestamp }}
+
{{ message }}
+
+ +{% if response %} +
+
{{ agent.name | upper }} // {{ timestamp }}
+
{{ response }}
+
+ +{% elif error %} +
+
SYSTEM // {{ timestamp }}
+
{{ error }}
+
+ +{% elif task_id %} +
+
{{ agent.name | upper }} // {{ timestamp }}
+
+ TASK ASSIGNED
+ {{ task_id[:8] }}… + · awaiting execution +
+
+{% endif %} diff --git a/src/dashboard/templates/partials/agent_panel.html b/src/dashboard/templates/partials/agent_panel.html new file mode 100644 index 00000000..c8e4c3c8 --- /dev/null +++ b/src/dashboard/templates/partials/agent_panel.html @@ -0,0 +1,82 @@ +{% set dot = "green" if agent.status == "idle" else ("amber" if agent.status == "busy" else "red") %} + +
+
+ + +
+ + + // {{ agent.name | upper }} + + {{ agent.capabilities or "no capabilities listed" }} + + + +
+ + +
+ + {% if tasks %} +
+ RECENT TASKS +
+ {% for task in tasks %} +
+
+ TASK · {{ task.status.value | upper }} · {{ task.created_at[:19].replace("T"," ") }} +
+
+
{{ task.description }}
+ {% if task.result %} +
{{ task.result }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + +
+ +
+ + + + +
+
+ + diff --git a/src/dashboard/templates/partials/swarm_agents_sidebar.html b/src/dashboard/templates/partials/swarm_agents_sidebar.html new file mode 100644 index 00000000..8028decf --- /dev/null +++ b/src/dashboard/templates/partials/swarm_agents_sidebar.html @@ -0,0 +1,50 @@ +
// SWARM AGENTS
+
+ +{% if not agents %} +
+ NO AGENTS REGISTERED +
+{% endif %} + +{% for agent in agents %} +{% set dot = "green" if agent.status == "idle" else ("amber" if agent.status == "busy" else "red") %} +
+ +
+ + {{ agent.name | upper }} +
+ +
+ STATUS + {{ agent.status }}
+ {% if agent.capabilities %} + CAPS + {{ agent.capabilities }}
+ {% endif %} + SEEN + {{ agent.last_seen[:19].replace("T"," ") if agent.last_seen else "—" }} +
+ +
+ + +
+ +
+{% endfor %} + +
diff --git a/src/dashboard/templates/partials/task_assign_panel.html b/src/dashboard/templates/partials/task_assign_panel.html new file mode 100644 index 00000000..53a51889 --- /dev/null +++ b/src/dashboard/templates/partials/task_assign_panel.html @@ -0,0 +1,60 @@ +
+
+ +
+ // CREATE TASK + +
+ +
+ +
+ +
+
+ DESCRIPTION +
+ +
+ +
+
+ ASSIGN TO +
+ +
+ + + +
+ +
+ +
+ +
+
diff --git a/src/dashboard/templates/partials/task_result.html b/src/dashboard/templates/partials/task_result.html new file mode 100644 index 00000000..cac57128 --- /dev/null +++ b/src/dashboard/templates/partials/task_result.html @@ -0,0 +1,28 @@ +{% set status_color = "green" if task.status.value == "completed" else ("red" if task.status.value == "failed" else "amber") %} +
+
+ TASK POSTED · {{ timestamp }} +
+
+ {{ task.description }} +
+
+ + STATUS + {{ task.status.value | upper }} + + + AGENT + {{ agent_name | upper }} + + + ID + {{ task.id[:8] }}… + +
+ {% if task.result %} +
+ {{ task.result }} +
+ {% endif %} +
diff --git a/src/dashboard/templates/partials/timmy_panel.html b/src/dashboard/templates/partials/timmy_panel.html new file mode 100644 index 00000000..9a4a2501 --- /dev/null +++ b/src/dashboard/templates/partials/timmy_panel.html @@ -0,0 +1,58 @@ +
+
+ +
+ + {% if agent %} + + {% endif %} + // TIMMY INTERFACE + + +
+ +
+ + + +
+
+ + diff --git a/src/swarm/agent_runner.py b/src/swarm/agent_runner.py index 4ca46912..0b81d625 100644 --- a/src/swarm/agent_runner.py +++ b/src/swarm/agent_runner.py @@ -1,17 +1,37 @@ """Sub-agent runner — entry point for spawned swarm agents. -This module is executed as a subprocess by swarm.manager. It creates a -SwarmNode, joins the registry, and waits for tasks. +This module is executed as a subprocess (or Docker container) by +swarm.manager / swarm.docker_runner. It creates a SwarmNode, joins the +registry, and waits for tasks. -Usage: +Comms mode is detected automatically: + +- **In-process / subprocess** (no ``COORDINATOR_URL`` env var): + Uses the shared in-memory SwarmComms channel directly. + +- **Docker container** (``COORDINATOR_URL`` is set): + Polls ``GET /internal/tasks`` and submits bids via + ``POST /internal/bids`` over HTTP. No in-memory state is shared + across the container boundary. + +Usage +----- +:: + + # Subprocess (existing behaviour — unchanged) python -m swarm.agent_runner --agent-id --name + + # Docker (coordinator_url injected via env) + COORDINATOR_URL=http://dashboard:8000 \ + python -m swarm.agent_runner --agent-id --name """ import argparse import asyncio import logging +import os +import random import signal -import sys logging.basicConfig( level=logging.INFO, @@ -20,6 +40,92 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# How often a Docker agent polls for open tasks (seconds) +_HTTP_POLL_INTERVAL = 5 + + +# ── In-process mode ─────────────────────────────────────────────────────────── + +async def _run_inprocess(agent_id: str, name: str, stop: asyncio.Event) -> None: + """Run the agent using the shared in-memory SwarmComms channel.""" + from swarm.swarm_node import SwarmNode + + node = SwarmNode(agent_id, name) + await node.join() + logger.info("Agent %s (%s) running (in-process mode) — waiting for tasks", name, agent_id) + try: + await stop.wait() + finally: + await node.leave() + logger.info("Agent %s (%s) shut down", name, agent_id) + + +# ── HTTP (Docker) mode ──────────────────────────────────────────────────────── + +async def _run_http( + agent_id: str, + name: str, + coordinator_url: str, + capabilities: str, + stop: asyncio.Event, +) -> None: + """Run the agent by polling the coordinator's internal HTTP API.""" + try: + import httpx + except ImportError: + logger.error("httpx is required for HTTP mode — install with: pip install httpx") + return + + from swarm import registry + + # Register in SQLite so the coordinator can see us + registry.register(name=name, capabilities=capabilities, agent_id=agent_id) + logger.info( + "Agent %s (%s) running (HTTP mode) — polling %s every %ds", + name, agent_id, coordinator_url, _HTTP_POLL_INTERVAL, + ) + + base = coordinator_url.rstrip("/") + seen_tasks: set[str] = set() + + async with httpx.AsyncClient(timeout=10.0) as client: + while not stop.is_set(): + try: + resp = await client.get(f"{base}/internal/tasks") + if resp.status_code == 200: + tasks = resp.json() + for task in tasks: + task_id = task["task_id"] + if task_id in seen_tasks: + continue + seen_tasks.add(task_id) + bid_sats = random.randint(10, 100) + await client.post( + f"{base}/internal/bids", + json={ + "task_id": task_id, + "agent_id": agent_id, + "bid_sats": bid_sats, + "capabilities": capabilities, + }, + ) + logger.info( + "Agent %s bid %d sats on task %s", + name, bid_sats, task_id, + ) + except Exception as exc: + logger.warning("HTTP poll error: %s", exc) + + try: + await asyncio.wait_for(stop.wait(), timeout=_HTTP_POLL_INTERVAL) + except asyncio.TimeoutError: + pass # normal — just means the stop event wasn't set + + registry.update_status(agent_id, "offline") + logger.info("Agent %s (%s) shut down", name, agent_id) + + +# ── Entry point ─────────────────────────────────────────────────────────────── async def main() -> None: parser = argparse.ArgumentParser(description="Swarm sub-agent runner") @@ -27,29 +133,24 @@ async def main() -> None: parser.add_argument("--name", required=True, help="Human-readable agent name") args = parser.parse_args() - # Lazy import to avoid circular deps at module level - from swarm.swarm_node import SwarmNode + agent_id = args.agent_id + name = args.name + coordinator_url = os.environ.get("COORDINATOR_URL", "") + capabilities = os.environ.get("AGENT_CAPABILITIES", "") - node = SwarmNode(args.agent_id, args.name) - await node.join() - - logger.info("Agent %s (%s) running — waiting for tasks", args.name, args.agent_id) - - # Run until terminated stop = asyncio.Event() def _handle_signal(*_): - logger.info("Agent %s received shutdown signal", args.name) + logger.info("Agent %s received shutdown signal", name) stop.set() for sig in (signal.SIGTERM, signal.SIGINT): signal.signal(sig, _handle_signal) - try: - await stop.wait() - finally: - await node.leave() - logger.info("Agent %s (%s) shut down", args.name, args.agent_id) + if coordinator_url: + await _run_http(agent_id, name, coordinator_url, capabilities, stop) + else: + await _run_inprocess(agent_id, name, stop) if __name__ == "__main__": diff --git a/src/swarm/coordinator.py b/src/swarm/coordinator.py index 4c72f675..1c9a4193 100644 --- a/src/swarm/coordinator.py +++ b/src/swarm/coordinator.py @@ -14,6 +14,7 @@ from typing import Optional from swarm.bidder import AuctionManager, Bid from swarm.comms import SwarmComms from swarm.manager import SwarmManager +from swarm.recovery import reconcile_on_startup from swarm.registry import AgentRecord from swarm import registry from swarm import stats as swarm_stats @@ -37,6 +38,7 @@ class SwarmCoordinator: self.auctions = AuctionManager() self.comms = SwarmComms() self._in_process_nodes: list = [] + self._recovery_summary = reconcile_on_startup() # ── Agent lifecycle ───────────────────────────────────────────────────── diff --git a/src/swarm/docker_runner.py b/src/swarm/docker_runner.py new file mode 100644 index 00000000..7eb113ce --- /dev/null +++ b/src/swarm/docker_runner.py @@ -0,0 +1,187 @@ +"""Docker-backed agent runner — spawn swarm agents as isolated containers. + +Drop-in complement to SwarmManager. Instead of Python subprocesses, +DockerAgentRunner launches each agent as a Docker container that shares +the data volume and communicates with the coordinator over HTTP. + +Requirements +------------ +- Docker Engine running on the host (``docker`` CLI in PATH) +- The ``timmy-time:latest`` image already built (``make docker-build``) +- ``data/`` directory exists and is mounted at ``/app/data`` in each container + +Communication +------------- +Container agents use the coordinator's internal HTTP API rather than the +in-memory SwarmComms channel:: + + GET /internal/tasks → poll for tasks open for bidding + POST /internal/bids → submit a bid + +The ``COORDINATOR_URL`` env var tells agents where to reach the coordinator. +Inside the docker-compose network this is ``http://dashboard:8000``. +From the host it is typically ``http://localhost:8000``. + +Usage +----- +:: + + from swarm.docker_runner import DockerAgentRunner + + runner = DockerAgentRunner() + info = runner.spawn("Echo", capabilities="summarise,translate") + print(info) # {"container_id": "...", "name": "Echo", "agent_id": "..."} + + runner.stop(info["container_id"]) + runner.stop_all() +""" + +import logging +import subprocess +import uuid +from dataclasses import dataclass, field +from typing import Optional + +logger = logging.getLogger(__name__) + +DEFAULT_IMAGE = "timmy-time:latest" +DEFAULT_COORDINATOR_URL = "http://dashboard:8000" + + +@dataclass +class ManagedContainer: + container_id: str + agent_id: str + name: str + image: str + capabilities: str = "" + + +class DockerAgentRunner: + """Spawn and manage swarm agents as Docker containers.""" + + def __init__( + self, + image: str = DEFAULT_IMAGE, + coordinator_url: str = DEFAULT_COORDINATOR_URL, + extra_env: Optional[dict] = None, + ) -> None: + self.image = image + self.coordinator_url = coordinator_url + self.extra_env = extra_env or {} + self._containers: dict[str, ManagedContainer] = {} + + # ── Public API ──────────────────────────────────────────────────────────── + + def spawn( + self, + name: str, + agent_id: Optional[str] = None, + capabilities: str = "", + image: Optional[str] = None, + ) -> dict: + """Spawn a new agent container and return its info dict. + + The container runs ``python -m swarm.agent_runner`` and communicates + with the coordinator over HTTP via ``COORDINATOR_URL``. + """ + aid = agent_id or str(uuid.uuid4()) + img = image or self.image + container_name = f"timmy-agent-{aid[:8]}" + + env_flags = self._build_env_flags(aid, name, capabilities) + + cmd = [ + "docker", "run", + "--detach", + "--name", container_name, + "--network", "timmy-time_swarm-net", + "--volume", "timmy-time_timmy-data:/app/data", + "--extra-hosts", "host.docker.internal:host-gateway", + *env_flags, + img, + "python", "-m", "swarm.agent_runner", + "--agent-id", aid, + "--name", name, + ] + + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=15 + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip()) + container_id = result.stdout.strip() + except FileNotFoundError: + raise RuntimeError( + "Docker CLI not found. Is Docker Desktop running?" + ) + + managed = ManagedContainer( + container_id=container_id, + agent_id=aid, + name=name, + image=img, + capabilities=capabilities, + ) + self._containers[container_id] = managed + logger.info( + "Docker agent %s (%s) started — container %s", + name, aid, container_id[:12], + ) + return { + "container_id": container_id, + "agent_id": aid, + "name": name, + "image": img, + "capabilities": capabilities, + } + + def stop(self, container_id: str) -> bool: + """Stop and remove a container agent.""" + try: + subprocess.run( + ["docker", "rm", "-f", container_id], + capture_output=True, timeout=10, + ) + self._containers.pop(container_id, None) + logger.info("Docker agent container %s stopped", container_id[:12]) + return True + except Exception as exc: + logger.error("Failed to stop container %s: %s", container_id[:12], exc) + return False + + def stop_all(self) -> int: + """Stop all containers managed by this runner.""" + ids = list(self._containers.keys()) + stopped = sum(1 for cid in ids if self.stop(cid)) + return stopped + + def list_containers(self) -> list[ManagedContainer]: + return list(self._containers.values()) + + def is_running(self, container_id: str) -> bool: + """Return True if the container is currently running.""" + try: + result = subprocess.run( + ["docker", "inspect", "--format", "{{.State.Running}}", container_id], + capture_output=True, text=True, timeout=5, + ) + return result.stdout.strip() == "true" + except Exception: + return False + + # ── Internal ────────────────────────────────────────────────────────────── + + def _build_env_flags(self, agent_id: str, name: str, capabilities: str) -> list[str]: + env = { + "COORDINATOR_URL": self.coordinator_url, + "AGENT_NAME": name, + "AGENT_ID": agent_id, + "AGENT_CAPABILITIES": capabilities, + **self.extra_env, + } + flags = [] + for k, v in env.items(): + flags += ["--env", f"{k}={v}"] + return flags diff --git a/src/swarm/recovery.py b/src/swarm/recovery.py new file mode 100644 index 00000000..0e16dcf0 --- /dev/null +++ b/src/swarm/recovery.py @@ -0,0 +1,90 @@ +"""Swarm startup recovery — reconcile SQLite state after a restart. + +When the server stops unexpectedly, tasks may be left in BIDDING, ASSIGNED, +or RUNNING states, and agents may still appear as 'idle' or 'busy' in the +registry even though no live process backs them. + +``reconcile_on_startup()`` is called once during coordinator initialisation. +It performs two lightweight SQLite operations: + +1. **Orphaned tasks** — any task in BIDDING, ASSIGNED, or RUNNING is moved + to FAILED with a ``result`` explaining the reason. PENDING tasks are left + alone (they haven't been touched yet and can be re-auctioned). + +2. **Stale agents** — every agent record that is not already 'offline' is + marked 'offline'. Agents re-register themselves when they re-spawn; the + coordinator singleton stays the source of truth for which nodes are live. + +The function returns a summary dict useful for logging and tests. +""" + +import logging +from datetime import datetime, timezone + +from swarm import registry +from swarm.tasks import TaskStatus, list_tasks, update_task + +logger = logging.getLogger(__name__) + +#: Task statuses that indicate in-flight work that can't resume after restart. +_ORPHAN_STATUSES = {TaskStatus.BIDDING, TaskStatus.ASSIGNED, TaskStatus.RUNNING} + + +def reconcile_on_startup() -> dict: + """Reconcile swarm SQLite state after a server restart. + + Returns a dict with keys: + tasks_failed - number of orphaned tasks moved to FAILED + agents_offlined - number of stale agent records marked offline + """ + tasks_failed = _rescue_orphaned_tasks() + agents_offlined = _offline_stale_agents() + + summary = {"tasks_failed": tasks_failed, "agents_offlined": agents_offlined} + + if tasks_failed or agents_offlined: + logger.info( + "Swarm recovery: %d task(s) failed, %d agent(s) offlined", + tasks_failed, + agents_offlined, + ) + else: + logger.debug("Swarm recovery: nothing to reconcile") + + return summary + + +# ── Internal helpers ────────────────────────────────────────────────────────── + + +def _rescue_orphaned_tasks() -> int: + """Move BIDDING / ASSIGNED / RUNNING tasks to FAILED. + + Returns the count of tasks updated. + """ + now = datetime.now(timezone.utc).isoformat() + count = 0 + for task in list_tasks(): + if task.status in _ORPHAN_STATUSES: + update_task( + task.id, + status=TaskStatus.FAILED, + result="Server restarted — task did not complete.", + completed_at=now, + ) + count += 1 + return count + + +def _offline_stale_agents() -> int: + """Mark every non-offline agent as 'offline'. + + Returns the count of agent records updated. + """ + agents = registry.list_agents() + count = 0 + for agent in agents: + if agent.status != "offline": + registry.update_status(agent.id, "offline") + count += 1 + return count diff --git a/src/timmy/docker_agent.py b/src/timmy/docker_agent.py new file mode 100644 index 00000000..b84f8dfb --- /dev/null +++ b/src/timmy/docker_agent.py @@ -0,0 +1,139 @@ +"""Timmy — standalone Docker container entry point. + +Runs Timmy as an independent swarm participant: + 1. Registers "timmy" in the SQLite registry with capabilities + 2. Sends heartbeats every 30 s so the dashboard can track liveness + 3. Polls the coordinator for tasks assigned to "timmy" + 4. Executes them through the Agno/Ollama backend + 5. Marks each task COMPLETED (or FAILED) via the internal HTTP API + +Usage (Docker):: + + COORDINATOR_URL=http://dashboard:8000 \ + OLLAMA_URL=http://host.docker.internal:11434 \ + python -m timmy.docker_agent + +Environment variables +--------------------- +COORDINATOR_URL Where to reach the dashboard (required) +OLLAMA_URL Ollama base URL (default: http://localhost:11434) +TIMMY_AGENT_ID Override the registry ID (default: "timmy") +""" + +import asyncio +import logging +import os +import signal + +import httpx + +from swarm import registry + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(name)s — %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + +AGENT_ID = os.environ.get("TIMMY_AGENT_ID", "timmy") +COORDINATOR = os.environ.get("COORDINATOR_URL", "").rstrip("/") +POLL_INTERVAL = 5 # seconds between task polls +HEARTBEAT_INTERVAL = 30 + + +async def _run_task(task_id: str, description: str, client: httpx.AsyncClient) -> None: + """Execute a task using Timmy's AI backend and report the result.""" + logger.info("Timmy executing task %s: %s", task_id, description[:60]) + result = None + try: + from timmy.agent import create_timmy + agent = create_timmy() + run = agent.run(description, stream=False) + result = run.content if hasattr(run, "content") else str(run) + logger.info("Task %s completed", task_id) + except Exception as exc: + result = f"Timmy error: {exc}" + logger.warning("Task %s failed: %s", task_id, exc) + + # Report back to coordinator via HTTP + try: + await client.post( + f"{COORDINATOR}/swarm/tasks/{task_id}/complete", + data={"result": result or "(no output)"}, + ) + except Exception as exc: + logger.error("Could not report task %s result: %s", task_id, exc) + + +async def _heartbeat_loop(stop: asyncio.Event) -> None: + while not stop.is_set(): + try: + registry.heartbeat(AGENT_ID) + except Exception as exc: + logger.warning("Heartbeat error: %s", exc) + try: + await asyncio.wait_for(stop.wait(), timeout=HEARTBEAT_INTERVAL) + except asyncio.TimeoutError: + pass + + +async def _task_loop(stop: asyncio.Event) -> None: + seen: set[str] = set() + async with httpx.AsyncClient(timeout=10.0) as client: + while not stop.is_set(): + try: + resp = await client.get(f"{COORDINATOR}/swarm/tasks?status=assigned") + if resp.status_code == 200: + for task in resp.json().get("tasks", []): + if task.get("assigned_agent") != AGENT_ID: + continue + task_id = task["id"] + if task_id in seen: + continue + seen.add(task_id) + asyncio.create_task( + _run_task(task_id, task["description"], client) + ) + except Exception as exc: + logger.warning("Task poll error: %s", exc) + + try: + await asyncio.wait_for(stop.wait(), timeout=POLL_INTERVAL) + except asyncio.TimeoutError: + pass + + +async def main() -> None: + if not COORDINATOR: + logger.error("COORDINATOR_URL is not set — exiting") + return + + # Register Timmy in the shared SQLite registry + registry.register( + name="Timmy", + capabilities="chat,reasoning,research,planning", + agent_id=AGENT_ID, + ) + logger.info("Timmy registered (id=%s) — coordinator: %s", AGENT_ID, COORDINATOR) + + stop = asyncio.Event() + + def _handle_signal(*_): + logger.info("Timmy received shutdown signal") + stop.set() + + for sig in (signal.SIGTERM, signal.SIGINT): + signal.signal(sig, _handle_signal) + + await asyncio.gather( + _heartbeat_loop(stop), + _task_loop(stop), + ) + + registry.update_status(AGENT_ID, "offline") + logger.info("Timmy shut down") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/static/bg.svg b/static/bg.svg new file mode 100644 index 00000000..f981c257 --- /dev/null +++ b/static/bg.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/style.css b/static/style.css index 843d3d42..a70f70ad 100644 --- a/static/style.css +++ b/static/style.css @@ -1,20 +1,22 @@ -/* ── Mission Control palette ──────────────────────── */ +/* ── Arcane palette ────────────────────────────────── */ :root { - --bg-deep: #060d14; - --bg-panel: #0c1824; - --bg-card: #0f2030; - --border: #1a3a55; - --border-glow: #1e4d72; - --text: #b8d0e8; - --text-dim: #4a7a9a; - --text-bright: #ddeeff; + --bg-deep: #080412; + --bg-panel: #110820; + --bg-card: #180d2e; + --border: #3b1a5c; + --border-glow: #7c3aed; + --text: #c8b0e0; + --text-dim: #6b4a8a; + --text-bright: #ede0ff; --green: #00e87a; --green-dim: #00704a; --amber: #ffb800; --amber-dim: #7a5800; --red: #ff4455; --red-dim: #7a1a22; - --blue: #00aaff; + --blue: #ff7a2a; /* orange replaces blue as the primary accent */ + --orange: #ff7a2a; + --purple: #a855f7; --font: 'JetBrains Mono', 'Courier New', monospace; --header-h: 52px; @@ -36,7 +38,10 @@ body { font-family: var(--font); - background: var(--bg-deep); + background-color: var(--bg-deep); + background-image: url('/static/bg.svg'); + background-size: cover; + background-position: center top; color: var(--text); font-size: 13px; min-height: 100dvh; @@ -51,7 +56,9 @@ body { align-items: center; padding: 12px 24px; padding-top: max(12px, env(safe-area-inset-top)); - background: var(--bg-panel); + background: rgba(17, 8, 32, 0.86); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid var(--border); position: sticky; top: 0; @@ -64,6 +71,7 @@ body { font-weight: 700; color: var(--text-bright); letter-spacing: 0.15em; + text-shadow: 0 0 18px rgba(168, 85, 247, 0.55), 0 0 40px rgba(168, 85, 247, 0.25); } .mc-subtitle { font-size: 11px; @@ -73,8 +81,9 @@ body { } .mc-time { font-size: 14px; - color: var(--blue); + color: var(--orange); letter-spacing: 0.1em; + text-shadow: 0 0 10px rgba(249, 115, 22, 0.4); } .mc-test-link { font-size: 9px; @@ -88,13 +97,13 @@ body { transition: border-color 0.15s, color 0.15s; touch-action: manipulation; } -.mc-test-link:hover { border-color: var(--blue); color: var(--blue); } +.mc-test-link:hover { border-color: var(--purple); color: var(--purple); } /* ── Main layout ─────────────────────────────────── */ .mc-main { padding: 16px; height: calc(100dvh - var(--header-h)); - overflow: clip; /* clip = visual clipping only, no scroll container; lets trackpad events reach scrollable children */ + overflow: clip; } .mc-content { height: 100%; @@ -106,7 +115,7 @@ body { /* ── Sidebar ─────────────────────────────────────── */ .mc-sidebar { overflow-y: auto; - min-height: 0; /* allow flex item to shrink so overflow-y: auto actually triggers */ + min-height: 0; } /* ── Chat column ─────────────────────────────────── */ @@ -115,17 +124,19 @@ body { } .mc-chat-panel > .card { height: 100%; - overflow: clip; /* visual clip only, preserves scroll events to .chat-log child */ + overflow: clip; } /* ── Panel / Card overrides ──────────────────────── */ .mc-panel { - background: var(--bg-panel); + background: rgba(17, 8, 32, 0.78); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 4px; } .mc-panel-header { - background: var(--bg-card); + background: rgba(24, 10, 45, 0.90); border-bottom: 1px solid var(--border); font-size: 10px; font-weight: 700; @@ -140,7 +151,7 @@ body { border: 1px solid var(--border); border-radius: 3px; padding: 12px; - background: var(--bg-card); + background: rgba(24, 10, 45, 0.82); } .status-dot { width: 8px; @@ -175,7 +186,7 @@ body { .health-row:last-child { border-bottom: none; } .health-label { color: var(--text-dim); letter-spacing: 0.08em; } -/* Status badges (use Bootstrap .badge base + mc-badge-* modifier) */ +/* Status badges */ .mc-badge-up { background: var(--green-dim) !important; color: var(--green) !important; font-size: 10px; letter-spacing: 0.12em; border-radius: 2px; } .mc-badge-down { background: var(--red-dim) !important; color: var(--red) !important; font-size: 10px; letter-spacing: 0.12em; border-radius: 2px; } .mc-badge-ready { background: var(--amber-dim) !important; color: var(--amber) !important; font-size: 10px; letter-spacing: 0.12em; border-radius: 2px; } @@ -193,12 +204,12 @@ body { margin-bottom: 4px; letter-spacing: 0.12em; } -.chat-message.user .msg-meta { color: var(--blue); } -.chat-message.agent .msg-meta { color: var(--green); } +.chat-message.user .msg-meta { color: var(--orange); } +.chat-message.agent .msg-meta { color: var(--purple); } .chat-message.error-msg .msg-meta { color: var(--red); } .msg-body { - background: var(--bg-card); + background: rgba(24, 10, 45, 0.80); border: 1px solid var(--border); border-radius: 3px; padding: 10px 12px; @@ -207,14 +218,14 @@ body { word-break: break-word; } .chat-message.user .msg-body { border-color: var(--border-glow); } -.chat-message.agent .msg-body { border-left: 3px solid var(--green); } +.chat-message.agent .msg-body { border-left: 3px solid var(--purple); } .chat-message.error-msg .msg-body { border-left: 3px solid var(--red); color: var(--red); } /* ── Chat input footer ───────────────────────────── */ .mc-chat-footer { padding: 12px 14px; padding-bottom: max(12px, env(safe-area-inset-bottom)); - background: var(--bg-card); + background: rgba(24, 10, 45, 0.90); border-top: 1px solid var(--border); flex-shrink: 0; } @@ -237,7 +248,7 @@ body { /* Bootstrap form-control overrides */ .mc-input { - background: var(--bg-deep) !important; + background: rgba(8, 4, 18, 0.75) !important; border: 1px solid var(--border) !important; border-radius: 3px !important; color: var(--text-bright) !important; @@ -246,7 +257,7 @@ body { } .mc-input:focus { border-color: var(--border-glow) !important; - box-shadow: 0 0 0 1px var(--border-glow) !important; + box-shadow: 0 0 0 1px var(--border-glow), 0 0 10px rgba(124, 58, 237, 0.25) !important; } .mc-input::placeholder { color: var(--text-dim) !important; } @@ -260,11 +271,15 @@ body { font-weight: 700; padding: 8px 18px; letter-spacing: 0.12em; - transition: background 0.15s, color 0.15s; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; touch-action: manipulation; white-space: nowrap; } -.mc-btn-send:hover { background: var(--blue); color: var(--bg-deep); } +.mc-btn-send:hover { + background: var(--orange); + color: #080412; + box-shadow: 0 0 14px rgba(249, 115, 22, 0.45); +} /* ── HTMX Loading ────────────────────────────────── */ .htmx-indicator { display: none; } @@ -274,7 +289,7 @@ body { /* ── Scrollbar ───────────────────────────────────── */ ::-webkit-scrollbar { width: 4px; } -::-webkit-scrollbar-track { background: var(--bg-deep); } +::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } ::-webkit-scrollbar-thumb:hover { background: var(--border-glow); } diff --git a/tests/conftest.py b/tests/conftest.py index c2504413..f20203bf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,23 @@ def reset_message_log(): message_log.clear() +@pytest.fixture(autouse=True) +def reset_coordinator_state(): + """Clear the coordinator's in-memory state between tests. + + The coordinator singleton is created at import time and persists across + the test session. Without this fixture, agents spawned in one test bleed + into the next through the auctions dict, comms listeners, and the + in-process node list. + """ + yield + from swarm.coordinator import coordinator + coordinator.auctions._auctions.clear() + coordinator.comms._listeners.clear() + coordinator._in_process_nodes.clear() + coordinator.manager.stop_all() + + @pytest.fixture def client(): from dashboard.app import app diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3bc5241c..8f52f933 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -15,7 +15,8 @@ def test_index_contains_title(client): def test_index_contains_chat_interface(client): response = client.get("/") - assert "TIMMY INTERFACE" in response.text + # Timmy panel loads dynamically via HTMX; verify the trigger attribute is present + assert "hx-get=\"/agents/timmy/panel\"" in response.text # ── Health ──────────────────────────────────────────────────────────────────── diff --git a/tests/test_mobile_scenarios.py b/tests/test_mobile_scenarios.py index 1e15e730..050a1b43 100644 --- a/tests/test_mobile_scenarios.py +++ b/tests/test_mobile_scenarios.py @@ -30,6 +30,11 @@ def _index_html(client) -> str: return client.get("/").text +def _timmy_panel_html(client) -> str: + """Fetch the Timmy chat panel (loaded dynamically from index via HTMX).""" + return client.get("/agents/timmy/panel").text + + # ── M1xx — Viewport & meta tags ─────────────────────────────────────────────── def test_M101_viewport_meta_present(client): @@ -120,25 +125,25 @@ def test_M301_input_font_size_16px_in_mobile_query(): def test_M302_input_autocapitalize_none(client): """autocapitalize=none prevents iOS from capitalising chat commands.""" - html = _index_html(client) + html = _timmy_panel_html(client) assert 'autocapitalize="none"' in html def test_M303_input_autocorrect_off(client): """autocorrect=off prevents iOS from mangling technical / proper-noun input.""" - html = _index_html(client) + html = _timmy_panel_html(client) assert 'autocorrect="off"' in html def test_M304_input_enterkeyhint_send(client): """enterkeyhint=send labels the iOS return key 'Send' for clearer UX.""" - html = _index_html(client) + html = _timmy_panel_html(client) assert 'enterkeyhint="send"' in html def test_M305_input_spellcheck_false(client): """spellcheck=false prevents red squiggles on technical terms.""" - html = _index_html(client) + html = _timmy_panel_html(client) assert 'spellcheck="false"' in html @@ -146,19 +151,19 @@ def test_M305_input_spellcheck_false(client): def test_M401_form_hx_sync_drop(client): """hx-sync=this:drop discards duplicate submissions (fast double-tap).""" - html = _index_html(client) + html = _timmy_panel_html(client) assert 'hx-sync="this:drop"' in html def test_M402_form_hx_disabled_elt(client): """hx-disabled-elt disables the SEND button while a request is in-flight.""" - html = _index_html(client) + html = _timmy_panel_html(client) assert "hx-disabled-elt" in html def test_M403_form_hx_indicator(client): """hx-indicator wires up the loading spinner to the in-flight state.""" - html = _index_html(client) + html = _timmy_panel_html(client) assert "hx-indicator" in html diff --git a/tests/test_swarm_recovery.py b/tests/test_swarm_recovery.py new file mode 100644 index 00000000..4076bee9 --- /dev/null +++ b/tests/test_swarm_recovery.py @@ -0,0 +1,179 @@ +"""Tests for swarm.recovery — startup reconciliation logic.""" + +import pytest + + +@pytest.fixture(autouse=True) +def tmp_swarm_db(tmp_path, monkeypatch): + """Isolate SQLite writes to a temp directory.""" + db = tmp_path / "swarm.db" + monkeypatch.setattr("swarm.tasks.DB_PATH", db) + monkeypatch.setattr("swarm.registry.DB_PATH", db) + monkeypatch.setattr("swarm.stats.DB_PATH", db) + yield db + + +# ── reconcile_on_startup: return shape ─────────────────────────────────────── + +def test_reconcile_returns_summary_keys(): + from swarm.recovery import reconcile_on_startup + result = reconcile_on_startup() + assert "tasks_failed" in result + assert "agents_offlined" in result + + +def test_reconcile_empty_db_returns_zeros(): + from swarm.recovery import reconcile_on_startup + result = reconcile_on_startup() + assert result["tasks_failed"] == 0 + assert result["agents_offlined"] == 0 + + +# ── Orphaned task rescue ────────────────────────────────────────────────────── + +def test_reconcile_fails_bidding_task(): + from swarm.tasks import create_task, get_task, update_task, TaskStatus + from swarm.recovery import reconcile_on_startup + + task = create_task("Orphaned bidding task") + update_task(task.id, status=TaskStatus.BIDDING) + + result = reconcile_on_startup() + + assert result["tasks_failed"] == 1 + rescued = get_task(task.id) + assert rescued.status == TaskStatus.FAILED + assert rescued.result is not None + assert rescued.completed_at is not None + + +def test_reconcile_fails_running_task(): + from swarm.tasks import create_task, get_task, update_task, TaskStatus + from swarm.recovery import reconcile_on_startup + + task = create_task("Orphaned running task") + update_task(task.id, status=TaskStatus.RUNNING) + + result = reconcile_on_startup() + assert result["tasks_failed"] == 1 + assert get_task(task.id).status == TaskStatus.FAILED + + +def test_reconcile_fails_assigned_task(): + from swarm.tasks import create_task, get_task, update_task, TaskStatus + from swarm.recovery import reconcile_on_startup + + task = create_task("Orphaned assigned task") + update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent="agent-x") + + result = reconcile_on_startup() + assert result["tasks_failed"] == 1 + assert get_task(task.id).status == TaskStatus.FAILED + + +def test_reconcile_leaves_pending_task_untouched(): + from swarm.tasks import create_task, get_task, TaskStatus + from swarm.recovery import reconcile_on_startup + + task = create_task("Pending task — should survive") + # status is PENDING by default + reconcile_on_startup() + assert get_task(task.id).status == TaskStatus.PENDING + + +def test_reconcile_leaves_completed_task_untouched(): + from swarm.tasks import create_task, update_task, get_task, TaskStatus + from swarm.recovery import reconcile_on_startup + + task = create_task("Completed task") + update_task(task.id, status=TaskStatus.COMPLETED, result="done") + + reconcile_on_startup() + assert get_task(task.id).status == TaskStatus.COMPLETED + + +def test_reconcile_counts_multiple_orphans(): + from swarm.tasks import create_task, update_task, TaskStatus + from swarm.recovery import reconcile_on_startup + + for status in (TaskStatus.BIDDING, TaskStatus.RUNNING, TaskStatus.ASSIGNED): + t = create_task(f"Orphan {status}") + update_task(t.id, status=status) + + result = reconcile_on_startup() + assert result["tasks_failed"] == 3 + + +# ── Stale agent offlined ────────────────────────────────────────────────────── + +def test_reconcile_offlines_idle_agent(): + from swarm import registry + from swarm.recovery import reconcile_on_startup + + agent = registry.register("IdleAgent") + assert agent.status == "idle" + + result = reconcile_on_startup() + assert result["agents_offlined"] == 1 + assert registry.get_agent(agent.id).status == "offline" + + +def test_reconcile_offlines_busy_agent(): + from swarm import registry + from swarm.recovery import reconcile_on_startup + + agent = registry.register("BusyAgent") + registry.update_status(agent.id, "busy") + + result = reconcile_on_startup() + assert result["agents_offlined"] == 1 + assert registry.get_agent(agent.id).status == "offline" + + +def test_reconcile_skips_already_offline_agent(): + from swarm import registry + from swarm.recovery import reconcile_on_startup + + agent = registry.register("OfflineAgent") + registry.update_status(agent.id, "offline") + + result = reconcile_on_startup() + assert result["agents_offlined"] == 0 + + +def test_reconcile_counts_multiple_stale_agents(): + from swarm import registry + from swarm.recovery import reconcile_on_startup + + registry.register("AgentA") + registry.register("AgentB") + registry.register("AgentC") + + result = reconcile_on_startup() + assert result["agents_offlined"] == 3 + + +# ── Coordinator integration ─────────────────────────────────────────────────── + +def test_coordinator_runs_recovery_on_init(): + """Coordinator.__init__ calls reconcile; _recovery_summary must be present.""" + from swarm.coordinator import SwarmCoordinator + coord = SwarmCoordinator() + assert hasattr(coord, "_recovery_summary") + assert "tasks_failed" in coord._recovery_summary + assert "agents_offlined" in coord._recovery_summary + coord.manager.stop_all() + + +def test_coordinator_recovery_cleans_stale_task(): + """End-to-end: task left in BIDDING is cleaned up by a fresh coordinator.""" + from swarm.tasks import create_task, get_task, update_task, TaskStatus + from swarm.coordinator import SwarmCoordinator + + task = create_task("Stale bidding task") + update_task(task.id, status=TaskStatus.BIDDING) + + coord = SwarmCoordinator() + assert get_task(task.id).status == TaskStatus.FAILED + assert coord._recovery_summary["tasks_failed"] >= 1 + coord.manager.stop_all()