Worker w tle dla TuttiTrip, planera wyjazdów grupowych i rodzinnych budowanego na HackYeah 2026. Wykonuje operacje, które trwają za długo na zwykłe zapytanie HTTP: uruchomienia agentów Pydantic AI (wywiad, uzasadnienia, parsowanie wklejonych planów, oferty, paragony), liczenie embeddingów do pgvector, wzbogacanie danych o miejscach i przeliczanie planów.
Każde takie zadanie jest workflowem DBOS. DBOS zapisuje postęp w Postgresie. Jeśli worker padnie albo zostanie zrestartowany w trakcie, po starcie kończy zadanie od ostatniego zapisanego kroku, więc wywołania modelu, które już się udały, nie są powtarzane.
Backend (tuttitrip-backend) tylko wrzuca zadania do kolejki i odczytuje ich stan, wynik oraz postęp. Workflowy, kolejki i kontrakt należą do tego repo.
Solver i inne reguły deterministyczne nie zależą od LLM: agenci przygotowują szkice, decyzje podejmuje czysty kod, a testy architektury tego pilnują.
Note
Opis całego projektu, plansze, zrzuty aplikacji i instrukcja uruchomienia wszystkich części są w repozytorium zbiorczym tuttitrip. TuttiTrip powstał z pomocą asystentów kodowania (Claude Code, Codex). Ludzie z zespołu odpowiadali za architekturę rozwiązania, rozplanowanie funkcji, działanie aplikacji i to, jak się z niej korzysta.
- uv i Python 3.14 (uv sam go pobierze),
- Docker,
- lokalnie uruchomiony backend: jego Postgres (z pgvector) i migracje,
- opcjonalnie Ollama z
nomic-embed-textdo embeddingów oraz klucz OpenRouter do agentów. Testy nie potrzebują ani jednego, ani drugiego.
Schemat bazy, migracje i tabele DBOS należą do backendu, więc najpierw uruchamiamy jego część:
cd ../tuttitrip-backend
docker compose up -d --wait db # Postgres 18 + pgvector
uv run alembic upgrade head # tabele aplikacji
uv run dbos migrate -s postgresql://tuttitrip:tuttitrip@localhost:5432/tuttitrip
uv run uvicorn tuttitrip.main:app --reload # API na :8000Potem worker, na hoście:
cd ../tuttitrip-worker
uv sync
cp .env.example .env # domyślne wartości pasują do compose backendu
uv run tuttitrip-workerAlbo w kontenerze podłączonym do sieci compose backendu (tuttitrip_default):
docker compose up --buildW logach powinny pojawić się DBOS launched!, Listening to 3 queues oraz
worker ready: env=local. Całą ścieżkę, od backendu przez kolejkę do workera,
sprawdzisz tak:
curl -X POST localhost:8000/api/v1/jobs/ping # zwraca workflow_id
curl localhost:8000/api/v1/jobs/ping/<workflow_id> # status SUCCESSJeśli Postgres backendu działa na innym porcie (np. 5433), zmień
TUTTITRIP_WORKER_DATABASE_URL w .env. Embeddingi wymagają ollama pull nomic-embed-text. Agent korzysta z OPENROUTER_API_KEY albo z lokalnego
endpointu zgodnego z OpenAI (TUTTITRIP_LLM__LOCAL_*).
uv run ruff check . # lint (select = ALL, preview)
uv run ruff format --check . # formatowanie
uv run ty check # typy, tryb ścisły
uv run pytest -m "not integration and not e2e" # testy jednostkowe i architektury, to samo robi CI
uv run pytest -m integration # lokalnie przed PR: runtime DBOS na SQLite (CI ich nie uruchamia)
uv run pytest # wszystko; Postgres ani sieć nie są potrzebneTesty oznaczone integration (lokalnie, CI ich nie uruchamia) uruchamiają prawdziwy runtime DBOS na tymczasowym pliku SQLite i
wrzucają zadania przez DBOSClient tak samo jak backend.
Modele zastępują TestModel i FunctionModel z Pydantic AI, a
ALLOW_MODEL_REQUESTS = False blokuje każde prawdziwe wywołanie. Kroki
zapisujące do Postgresa podmieniamy w testach workflowów, a ich SQL
sprawdzamy osobno.
CI uruchamia te same cztery komendy i dodatkowo sprawdza, czy
contracts/jobs.schema.json jest aktualny.
Dwa przykłady pracy workera (plansze na danych przykładowych):
Kod jest podzielony na pionowe plastry (vertical slices):
src/tuttitrip_worker/
contracts.py kontrakt z backendem: nazwy, payloady, wersja (czysty moduł)
main.py rejestracja workflowów, DBOS.launch(), obsługa SIGTERM
shared/ config, dbos (kolejki), llm (OpenRouter, model lokalny), db
system/ ping (smoke test) i heartbeat co 30 s
planning/ trwały agent planujący (generate_trip_plan)
linter/ parse_pasted_plan: wklejony plan do pozycji z cytatem
embeddings/ embed_texts → pgvector; logic/ to czysta logika
W domenie mamy workflows.py (deterministyczna orkiestracja), steps.py
(całe I/O: modele, HTTP, baza), schemas.py, agents.py (agenci
Pydantic AI z DBOSDurability) i opcjonalnie logic/ z czystym kodem.
Testy pytest-archon pilnują, żeby shared nie importował domen, domeny nie
sięgały do swoich wnętrz nawzajem, a moduły czyste (contracts.py, każdy
schemas.py i logic/) nie dotykały Pydantic AI, DBOS ani bazy, także
pośrednio. Pełne zasady, w tym pułapki DBOS, opisuje AGENTS.md.
Kolejki:
| Kolejka | Do czego | Limit |
|---|---|---|
default |
ping, embeddingi, wzbogacanie, przeliczenia | 8 naraz |
local_llm |
model lokalny na GPU (dellpromaxgb10) | 2 naraz |
openrouter |
modele przez OpenRouter | 8 naraz, 30 startów na minutę |
Wspólne zasady obu repozytoriów są w deploy/CONVENTIONS.md w repo backendu
(sekcja „Integracja z workerem”). W skrócie:
- Kontrakt. Źródłem prawdy jest
src/tuttitrip_worker/contracts.py: nazwy workflowów i kolejek, modele wejścia, wyjścia i zdarzeń orazCONTRACT_VERSION. Backend ma lustro wsrc/tuttitrip/shared/jobs/contracts.py. Oba repo generują ten sam plikcontracts/jobs.schema.json(uv run python scripts/export_contracts.py), a jobcontracts-checkw CI porównuje go z plikiem z drugiego repo. - Wersjonowanie. Każdy payload ma
contract_version. Worker odrzuca wersję, której nie obsługuje, czytelnym błędemContractError, a backend pokazuje go wGET /api/v1/jobs/{id}. Przy niezgodnej zmianie worker najpierw przyjmuje starą i nową wersję, potem backend przełącza się na nową, a na końcu worker porzuca starą. - Dane. Schemat i migracje należą do backendu. Worker nie wykonuje DDL
i łączy się jako rola
tuttitrip_worker: czyta tabele domenowe, a pisze tylko doembeddings,job_resultsiworker_heartbeats. Payloady są małe (identyfikatory i parametry), resztę danych worker pobiera z bazy. - Wyniki. Mały wynik jest wyjściem workflowu. Duże albo trwałe wyniki
trafiają do
job_resultslub do tabel domenowych. Postęp idzie przez zdarzenieprogress. Worker nigdy nie woła backendu po HTTP. - Idempotencja. Backend nadaje deterministyczne
workflow_id, a kroki workera robią upsert (np. id embeddingu to UUIDv5 ze źródła, modelu i tekstu). - Heartbeat. Co 30 s worker zapisuje się w
worker_heartbeats. Backend pokazuje to w/api/v1/healthi nie przyjmuje zadań, gdy workera brakuje. - Smoke test. Po wdrożeniu worker woła
POST /api/v1/jobs/pingna API swojego środowiska i czeka naSUCCESS. Brak wyniku oznacza nieudany deploy. - Wersja aplikacji i serializacja.
application_versionto nazwa środowiska (main,develop, slug gałęzi), stała po obu stronach, bo worker pobiera z kolejki tylko zadania ze swoją wersją. Argumenty i wyniki idą jako przenośny JSON (PORTABLE); domyślny pickle wymagałby tych samych klas Pythona po obu stronach.
Nowe zadanie dodajemy skillem new-workflow, a zmianę kontraktu
synchronizujemy skillem sync-contracts (oba w .claude/skills/).
Każdy push uruchamia CI raz: lint i tests równolegle na runnerach
[self-hosted, hackathon], contracts-check też na tych runnerach. Podgląd
gałęzi wdraża się od razu, main i develop czekają na zielone lint i
tests. Deploy idzie na runnerze zainstalowanym na dellpromaxgb10
([self-hosted, tuttitrip-worker-deploy]).
| Gałąź | Obraz | Kontener |
|---|---|---|
main |
tuttitrip-worker:main |
tuttitrip-worker-main |
develop |
tuttitrip-worker:develop |
tuttitrip-worker-develop |
| inna | tuttitrip-worker:<slug> |
tuttitrip-worker-<slug> |
Slug powstaje tak samo jak w backendzie: małe litery, a każdy ciąg znaków
spoza [a-z0-9] zamienia się na - (feature/cos tam → feature-cos-tam).
Co robi deploy (deploy/deploy.sh):
- Buduje obraz
tuttitrip-worker:<env>. - Jeśli środowisko backendu
<env>istnieje, (re)startuje kontenertuttitrip-worker-<env>w siecituttitrip, z plikiem~/tuttitrip/envs/<env>.worker.env(zapisuje go deploy backendu) oraz~/tuttitrip/worker.env(sekrety workera). Zastępuje przy tym workera zapasowego, którego backend mógł wcześniej uruchomić z:developlub:main. - Jeśli środowiska backendu nie ma (gałąź istnieje tylko w workerze), deploy tylko buduje obraz i niczego nie uruchamia. Kontener wystartuje przy pierwszym deployu backendu na gałęzi o tej samej nazwie.
- Czeka na healthcheck i uruchamia smoke test przez API.
- Sprząta: usuwa obrazy gałęzi, których nie ma już w tym repo. Jeśli
środowisko backendu takiej gałęzi wciąż działa, przełącza jego workera
na obraz zapasowy (
:develop, potem:main), żeby nie zostało bez workera.mainidevelopnie są nigdy usuwane.
Gdy backend wdraża środowisko X i nie ma kontenera tuttitrip-worker-X,
uruchamia go sam z tuttitrip-worker:X, a jeśli takiego obrazu nie ma, to
z :develop, a w ostateczności z :main.
Sekrety: OPENROUTER_API_KEY jest sekretem GitHub Actions, który deploy
dopisuje do ~/tuttitrip/worker.env na hoście. Embeddingi na hoście liczy
Ollama (nomic-embed-text), do której worker ma dostęp przez sieć ollama_net.
mainto produkcja,developto integracja. Na obu wymagany jest PR i zielonelintitests, bez force-push i bez usuwania (o ile plan GitHuba na to pozwala).- Gałęzie zakładamy od
develop:feature/<nazwa>,fix/<nazwa>,chore/<nazwa>. PR idzie dodevelop, a wydanie to PRdevelop→main. - Po merge'u gałąź usuwa workflow
Delete merged branchi uruchamia sprzątanie jej obrazu.mainidevelopnie są nigdy usuwane, więc PR wydania idzie prosto zdevelop. Automatyczne usuwanie gałęzi w ustawieniach GitHuba jest wyłączone, bo kasowałodeveloppo wydaniu. - Zmiana kontraktu trafia najpierw tutaj, a potem do lustra w backendzie.



