Whilly v4.0 Architecture
TL;DR. v4 is a Hexagonal (Ports & Adapters) Python service. The pure domain layer (
whilly/core/) knows nothing about Postgres, HTTP, or Claude — it’s a state-machine + DAG scheduler + prompt builder, no I/O. Everything that touches the outside world lives inwhilly/adapters/, driven by composition roots inwhilly/cli/andwhilly/worker/. The shape is enforced statically by.importlinterand verified at runtime by Postgres-backed integration tests.
Layout
whilly/
├── core/ # Pure domain. Zero external deps.
│ ├── models.py # Task, Plan, TaskStatus, WorkerId, Priority
│ ├── state_machine.py # apply_transition (Transition × TaskStatus → TaskStatus)
│ ├── scheduler.py # topological_sort, detect_cycles, next_ready
│ └── prompts.py # build_task_prompt — pure string templating
│
├── adapters/ # I/O. One sub-package per outside system.
│ ├── db/ # asyncpg + Alembic
│ │ ├── pool.py # create_pool / close_pool
│ │ ├── repository.py # TaskRepository — claim/start/complete/fail/release
│ │ ├── schema.sql # canonical schema (mirrors latest migration)
│ │ └── migrations/ # Alembic — env.py + versions/*.py
│ ├── transport/ # FastAPI + httpx
│ │ ├── server.py # create_app — FastAPI factory
│ │ ├── client.py # RemoteWorkerClient — httpx wrapper
│ │ ├── auth.py # bearer_dep / bootstrap_dep
│ │ └── schemas.py # pydantic wire DTOs
│ ├── runner/ # subprocess agent invocation
│ │ ├── claude_cli.py # asyncio.create_subprocess_exec wrapper
│ │ └── result_parser.py # parse_output → AgentResult
│ └── filesystem/
│ └── plan_io.py # JSON ↔ Plan/Task round-trip (import/export)
│
├── cli/ # Composition roots — argv → adapter wiring
│ ├── plan.py # `whilly plan import|export|show`
│ ├── run.py # `whilly run` — local worker
│ ├── worker.py # `whilly-worker` — remote worker (separate script)
│ └── dashboard.py # `whilly dashboard` — Rich Live TUI
│
├── worker/ # Async loops (claim → run → complete | fail)
│ ├── local.py # local worker — talks asyncpg directly
│ ├── main.py # local-worker heartbeat composition root
│ └── remote.py # remote worker — talks RemoteWorkerClient
│
└── cli_legacy.py # v3 CLI — kept for one release cycle, unused on v4 paths
The dependency rule
cli/ ─────────────► worker/ ─────► adapters/ ─────► core/
(composition) (loops) (I/O) (pure)
- Outer layers depend on inner layers, never the reverse.
corecannot import anything fromadapters,worker, orcli.adapterscannot import fromworker/cli.workercannot import fromcli. coreis dependency-free at runtime. Standard library only — no asyncpg, no httpx, no fastapi, no subprocess, no asyncio (well, see caveat below). The.importlintercore-puritycontract enforces:
[importlinter:contract:core-purity]
name = whilly.core must not import I/O or transport modules
type = forbidden
source_modules = whilly.core
forbidden_modules =
asyncpg
httpx
subprocess
fastapi
uvicorn
alembic
include_external_packages = True
CI runs lint-imports and a belt-and-suspenders grep for os.chdir / os.getcwd (TASK-029) — both pass on every commit to feat/v4-rewrite.
asynciocaveat:whilly.core.scheduler.next_readyis sync (just graph traversal);whilly.core.state_machine.apply_transitionis sync. Nothing in core touches an event loop.
Data flow — local worker shape
whilly run --plan <id>
│
▼
whilly/cli/run.py::run_run_command
├── opens asyncpg pool (whilly.adapters.db.pool.create_pool)
├── INSERT into workers (registers self via repo.register_worker)
└── invokes whilly/worker/main.py::run_worker
│
▼
whilly/worker/local.py::run_local_worker
│
▼ (one iteration)
┌─────────────────────────────────────────────────────────────────┐
│ claim_task(worker_id, plan_id) → tasks.status='CLAIMED' │
│ ↓ │
│ start_task(task.id, version) → tasks.status='IN_PROGRESS' │
│ ↓ │
│ run_task (whilly.adapters.runner.claude_cli) │
│ ↓ (asyncio.create_subprocess_exec → CLAUDE_BIN) │
│ ↓ parse_output → AgentResult(is_complete, exit_code) │
│ ↓ │
│ complete_task(task.id, version) → tasks.status='DONE' │
│ OR │
│ fail_task(task.id, version) → tasks.status='FAILED' │
└─────────────────────────────────────────────────────────────────┘
Data flow — remote worker shape (SC-3)
whilly-worker --connect URL --token X --plan <id>
│
▼
whilly/cli/worker.py::main
└── invokes whilly/worker/remote.py::run_remote_worker_with_heartbeat
│
▼ (one iteration over httpx)
┌─────────────────────────────────────────────────────────────────┐
│ POST /tasks/claim → 200 ClaimResponse | 204 │
│ ↓ (server-side long-poll loop) │
│ run_task (same runner as local — CLAUDE_BIN subprocess) │
│ ↓ │
│ POST /tasks/{id}/complete → 200 OK | 409 VersionConflict │
│ OR │
│ POST /tasks/{id}/fail → 200 OK | 409 VersionConflict │
└─────────────────────────────────────────────────────────────────┘
The remote worker never visits IN_PROGRESS — the HTTP transport doesn’t expose /tasks/{id}/start, and forcing a no-op start RPC just to satisfy a write-only filter would buy nothing observable. The state machine reflects this: (COMPLETE, CLAIMED) → DONE is a valid edge alongside the (COMPLETE, IN_PROGRESS) → DONE edge used by the local worker.
Concurrency primitives
- Optimistic locking (PRD FR-2.4). Every state-mutating SQL filters by
version; the UPDATE either matches one row (success, version incremented) or zero (conflict, raiseVersionConflictError). NoSELECT FOR UPDATE, no row-level locks held across writes — works trivially under HTTP concurrency, doesn’t deadlock the audit-event insert that fires in the same transaction. SKIP LOCKEDinclaim_task(PRD FR-1.3). Multiple workers can hammerclaim_tasksimultaneously; Postgres routes each to a different PENDING row without contention — proven bytests/integration/test_concurrent_claims.py(100 concurrent claimers, zero double-assignments).- Visibility-timeout sweep (TASK-025a). FastAPI lifespan starts a background task that flips claimed-but-stale rows back to PENDING after
WHILLY_VISIBILITY_TIMEOUTseconds. Mirrors SQS / RabbitMQ semantics — a SIGKILL’d worker’s task is recoverable without operator intervention. - Heartbeat-driven offline detection (TASK-025b). Workers POST
/workers/{id}/heartbeatevery 30s; a separate sweep flipsworkers.status='offline'after 2× the heartbeat interval and releases the worker’s in-flight tasks. End-to-end gated bytests/integration/test_phase6_resilience.py.
Audit log
Every state transition writes an events row in the same transaction as the tasks UPDATE. Schema:
| Column | Type | Meaning |
|---|---|---|
| id | BIGSERIAL | Monotonic — ORDER BY id is the canonical sort |
| task_id | TEXT | FK to tasks.id (CASCADE on delete) |
| event_type | TEXT | CLAIM / START / COMPLETE / FAIL / RELEASE |
| payload | JSONB | worker_id, version, error message, etc. |
| created_at | TIMESTAMPTZ | NOW() at INSERT |
The dashboard reads this table via a single SELECT — same projection every consumer uses, no view-side denormalization.
Why this shape
- Hexagonal lets us test core without booting Postgres. The 87-case state-machine truth table (
tests/unit/test_state_machine.py) and the 31-case scheduler suite run in <100ms total. They’d be impossible to write in <100ms if they had to spin up testcontainers. - Adapter swap is trivial. Want SQLite for a developer’s laptop? Swap
whilly.adapters.dbfor an SQLite version implementing the sameTaskRepositoryshape —worker/local.pyandcli/run.pydon’t notice. Same story for the runner (theAgentResultshape is the port; CLAUDE_BIN is one adapter, an LLM SDK could be another). - The boundary is a contract, not a guideline.
lint-importsruns in CI; a regression where someone imports asyncpg fromwhilly/core/scheduler.pyfor “just one query” fails the PR. The hexagonal split survives churn because violating it is mechanically impossible.
Pointers
- PRD:
docs/PRD-refactoring-1.md - Migration from v3:
docs/Whilly-v4-Migration-from-v3.md - Worker HTTP protocol:
docs/Whilly-v4-Worker-Protocol.md - Release checklist:
docs/v4.0-release-checklist.md