# DEV TASK — ORCH-5: идемпотентность webhook (M-7) **Проект:** orchestrator | **Сервер:** slin@82.22.50.71 | **Репо:** /home/slin/repos/orchestrator | **Контейнер:** orchestrator (8500) **Ветка:** `feature/ORCH-5-webhook-dedup` из свежего main (main содержит ORCH-1/1b/2/4/6/7). ⚠️ **ГРАБЛЯ push (ORCH-7, НЕ повтори):** после коммитов `git log origin/main..HEAD` (твои коммиты есть), после push `git log origin/main..origin/feature/ORCH-5-webhook-dedup` ДОЛЖЕН показать твои коммиты. НЕ отчитывайся «PR готов» пока remote-ветка реально не содержит коммитов. ## Проблема (M-7) Webhook-хендлеры (`src/webhooks/gitea.py`, `src/webhooks/plane.py`) при каждом запросе делают `INSERT INTO events` и затем диспетчеризуют → `enqueue_job(...)`. **Dedup'а НЕТ** — delivery-id нигде не читается (проверено grep'ом: нигде). Повторная доставка одного и того же вебхука (ретрай Gitea/Plane, сетевой реброд, ручной replay) → повторный enqueue → **дубль запуска конвейера**. Это тот же класс бага, что инцидент ET-009 (параллельные конвейеры на одном репо). **Цель:** сделать обработку webhook идемпотентной — один delivery обрабатывается ровно один раз. Повторный с тем же delivery-id → залогировать как duplicate и вернуть `{"status":"duplicate"}` БЕЗ диспетчеризации/enqueue. ## Текущее состояние (верифицировано по живому коду) - `gitea_webhook` (gitea.py:42): проверяет HMAC `X-Gitea-Signature` → INSERT events → диспетч (push/pr/status). - `plane_webhook` (plane.py:52): HMAC `X-Plane-Signature` → INSERT events → ORCH-6 project-filter → диспетч. - Таблица `events` (db.py:14): `id, timestamp, source, event_type, payload, processed` — **нет delivery-id, нет UNIQUE**. - Gitea шлёт заголовок **`X-Gitea-Delivery`** (GUID на доставку). Plane свой delivery-заголовок шлёт НЕнадёжно. ## Что сделать 1. **Миграция БД (паттерн `_ensure_column`, db.py:74):** - Добавить колонку `events.delivery_id TEXT` (idempotent через `_ensure_column`). - Создать UNIQUE-индекс: `CREATE UNIQUE INDEX IF NOT EXISTS idx_events_delivery ON events(delivery_id) WHERE delivery_id IS NOT NULL`. (partial unique — старые строки с NULL delivery_id не конфликтуют). 2. **Вычисление delivery-id (helper, напр. в `src/webhooks/_dedup.py` или в db.py):** - Gitea: `request.headers.get("X-Gitea-Delivery")`. Если пусто → fallback `sha256(source + event_type + body)`. - Plane: попробовать `X-Plane-Delivery`/`X-Hook-Delivery` если есть; иначе **fallback `sha256("plane" + body)`** (стабильный hash тела — повторная доставка идентичного тела даст тот же id). Префиксуй source чтобы gitea/plane не пересекались: итоговый `delivery_id = f"{source}:{raw_or_hash}"`. 3. **Идемпотентный INSERT + ранний выход:** - Заменить текущий `INSERT INTO events (source,event_type,payload)` на INSERT с `delivery_id` и `INSERT OR IGNORE` (или ловить `sqlite3.IntegrityError`). - Если строка НЕ вставилась (delivery уже есть) → `logger.info("webhook duplicate delivery_id=...")`, **вернуть `{"status":"duplicate"}` и НЕ вызывать диспетч/enqueue.** - Если вставилась (новый delivery) → продолжить как сейчас (диспетч). - ⚠️ Порядок: dedup-проверка ПОСЛЕ HMAC-верификации (невалидную подпись по-прежнему 401), для plane — реши: dedup до или после ORCH-6 project-filter. Рекомендация: dedup СРАЗУ после HMAC и INSERT, ДО project-filter (чтобы повторная доставка не делала лишнюю работу). Но НЕ сломай ORCH-6 поведение `{"status":"ignored"}` для unknown project на ПЕРВОЙ доставке. 4. **Не трогать** саму диспетч-логику, enqueue, ORCH-6 фильтр, stage_engine, очередь. ## Ограничения - 🚫 НЕ трогай: nginx, openclaw.json, .env-секреты, deploy-хук, Plane-webhook is_active, ORCH-1/1b/2/4/6/7, stage_engine.py, очередь, HMAC-проверку (только добавляешь dedup ПОСЛЕ неё). - ⚠️ Миграция должна быть restart-safe и не падать на уже существующей БД с данными (как `_ensure_column` для jobs). - ⚠️ 9 pre-existing webhook-тестов (401) — это baseline, НЕ чинить и НЕ ломать дополнительно. - Conventional Commits: `feat(webhook): dedup deliveries by delivery_id (M-7)`, `feat(db): add events.delivery_id + unique index`, `test(webhook): ...`. ## Тесты (в контейнере) - Новый `tests/test_webhook_dedup.py`: - первый webhook с delivery-id X → обрабатывается (status accepted), enqueue вызван. - повторный webhook с тем же delivery-id X → `{"status":"duplicate"}`, enqueue НЕ вызван (мок enqueue_job, assert call_count не вырос). - два РАЗНЫХ delivery-id → оба обработаны. - gitea без X-Gitea-Delivery → fallback hash работает (повтор того же тела = duplicate). - plane fallback hash аналогично. - миграция на «старой» БД без колонки delivery_id не падает. - Прогон: `IMG=$(docker inspect orchestrator --format '{{.Config.Image}}'); docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q` - Baseline: сейчас **136 passed**, 9 pre-existing 401 — не трогать. Новые зелёные, старые не сломать. ## Acceptance (проверит Стрим вживую) | # | Что | Критерий | |---|-----|----------| | 1 | миграция | `events.delivery_id` + partial UNIQUE index, restart-safe, не падает на проде-БД | | 2 | gitea dedup | повтор `X-Gitea-Delivery` → duplicate, без enqueue | | 3 | plane dedup | повтор тела → duplicate (fallback hash) | | 4 | HMAC цел | невалидная подпись по-прежнему 401 | | 5 | ORCH-6 цел | unknown project на первой доставке → ignored (не сломано) | | 6 | тесты | новые зелёные, baseline 136 не сломан | | 7 | прод | пересобран из ветки, health ok, /queue ok, миграция применилась на живой БД | ## Деплой + отчёт - `docker compose up -d --build && sleep 6 && curl -s :8500/health && curl -s :8500/queue` - Проверь что миграция применилась: `docker exec orchestrator python3 -c "import sqlite3;print([r[1] for r in sqlite3.connect('/app/data/orchestrator.db').execute('PRAGMA table_info(events)')])"` — должна быть `delivery_id`. - После каждого блока — короткий отчёт + результат. - Запушь ветку (ПРОВЕРЬ remote!), открой PR в main. **НЕ мержи** — мерж делает Стрим после проверки. - Других исполнителей на репо нет — только ты, не паникуй про параллельные сессии.