From 874cc29ff7b31461d57df5134fecf2a6bcbd9cf0 Mon Sep 17 00:00:00 2001 From: Slava Date: Wed, 10 Jun 2026 04:03:54 +0300 Subject: [PATCH 01/10] docs: init ORCH-100 business request --- docs/work-items/ORCH-100/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-100/00-business-request.md diff --git a/docs/work-items/ORCH-100/00-business-request.md b/docs/work-items/ORCH-100/00-business-request.md new file mode 100644 index 0000000..98d387d --- /dev/null +++ b/docs/work-items/ORCH-100/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: FND/F1b: sidecar-watchdog — сбор хост/контейнеры/деп + алертинг (отдельный контейнер, репо орка) + +Work Item ID: ORCH-100 + +## Description + +TBD From 36102f253f787f5111a93abaf66da146b740ee0b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 04:08:53 +0300 Subject: [PATCH 02/10] analyst(ET): auto-commit from analyst run_id=564 --- docs/work-items/ORCH-100/01-brd.md | 167 ++++++++++++++++++ docs/work-items/ORCH-100/02-trz.md | 155 ++++++++++++++++ .../ORCH-100/03-acceptance-criteria.md | 114 ++++++++++++ docs/work-items/ORCH-100/04-test-plan.yaml | 108 +++++++++++ 4 files changed, 544 insertions(+) create mode 100644 docs/work-items/ORCH-100/01-brd.md create mode 100644 docs/work-items/ORCH-100/02-trz.md create mode 100644 docs/work-items/ORCH-100/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-100/04-test-plan.yaml diff --git a/docs/work-items/ORCH-100/01-brd.md b/docs/work-items/ORCH-100/01-brd.md new file mode 100644 index 0000000..2bad4b8 --- /dev/null +++ b/docs/work-items/ORCH-100/01-brd.md @@ -0,0 +1,167 @@ +--- +work_item: ORCH-100 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-100 — FND/F1b: sidecar-watchdog (мозг мониторинга, отдельный контейнер) + +Work Item: **ORCH-100** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Задача — фундаментный кирпич **F1b** домена 0 «Фундамент» эпика автономного саморазвития +(`docs/epics/self-evolution.md`, §2, §«Архитектурные рамки наблюдаемости»). **F1a (ORCH-099)** уже +реализовал лёгкий read-only `GET /metrics` в самом орке — он отдаёт **только сырьё** (стадии, +очередь, agent-liveness, cost), без порогов/алертов/хранения. F1b — **вторая половина пары:** мозг +мониторинга, который это сырьё читает, дополняет внешними сигналами (хост, контейнеры, внешние +зависимости) и превращает в **алерты**. + +**Боль, которую закрывает F1b.** Сегодня платформа слепа к собственному здоровью в реальном +времени. Инциденты 06–09.06 (диск хоста молча дорос до 100% и встал весь конвейер — ORCH-063; +фантом-merge, deploy-петли, флапп-статусы, зомби-jobs) обнаруживались **постфактум, человеком**. +Частичные стражи существуют, но они **живут ВНУТРИ процесса орка** (`disk_watchdog` ORCH-063, +`reaper` ORCH-065, `reconciler` ORCH-053): если орк завис/съел память/упал — стражи лягут **вместе +с ним**, и платформа слепа именно в критический момент. + +**Архитектурная рамка — установленный факт заказчика (Слава, 09.06), не предмет переизобретения:** +- **C-1 / C-1б:** наблюдатель ОТДЕЛЁН от наблюдаемого. Sidecar-контейнер на том же хосте; КОД + sidecar — в репо орка (папка `watchdog/`), но рантайм — **ОТДЕЛЬНЫЙ контейнер** (свой Dockerfile + + сервис `orchestrator-watchdog` в `docker-compose.yml`). Изоляция — на уровне контейнера, не репо. +- **C-2:** без внешнего плеча (одна площадка; принятый риск — падёт весь хост → молчит и наблюдатель). +- **C-3:** тонкий стек — **НЕ Grafana/Prometheus**. Хост впритык: RAM 171Mi free / 7.7Gi, диск 92%. +- **Разделение ответственности:** орк отдаёт сырьё (`/metrics`), sidecar — мозг (пороги/алерты/свой + Telegram-канал, независимый от кода орка). Орк лёг → `/metrics` недоступен = **сам сигнал тревоги**. + +**Критический инвариант наблюдаемости:** падение/зависание орка должно делать sidecar **громче**, а +не тише. Если орк не отвечает на `/metrics` — sidecar жив и обязан зарепортить это как тревогу +«орк не отвечает». + +## 2. Объём (scope) + +### В объёме +- Новая папка `watchdog/` в репо орка: тонкий код sidecar + собственный `Dockerfile`. +- Сервис `orchestrator-watchdog` в `docker-compose.yml` (отдельный контейнер, свой рестарт/память). +- **Сбор сигналов** (периодический тик): (a) `GET /metrics` орка по HTTP; (b) хост — диск %/inode, + память, CPU; (c) контейнеры — через `docker.sock` **read-only** (статусы Up/healthy/restarting/ + exited/unhealthy); (d) пинг внешних зависимостей — Plane / Gitea / Anthropic. +- **Алертинг по порогам:** диск≥порог, память, agent-завис >N мин, job-failed, застрявшая стадия, + контейнер-down/unhealthy, внешняя зависимость недоступна, **орк-down (`/metrics` не отвечает)**. +- **Доставка:** Telegram через **СОБСТВЕННЫЙ канал sidecar** (свой токен/chat в `.env`), НЕ через + код/Telegram-функции орка. +- **Гигиена алертов:** дедупликация + throttle (один алерт на пересечение порога, не флапп) + + recovery-сообщение при возврате метрики в норму. +- **Управляемость:** kill-switch, конфигурируемые пороги, конфигурируемые интервалы. +- `.env.example`: токен/chat watchdog + пороги/интервалы (канон, без секретов). +- Документация (`07-infra-requirements.md` — разовое инфра-действие) + `CHANGELOG.md`; pytest зелёный. + +### Вне объёма +- **Любая авто-ремедиация** (рестарт контейнеров, очистка диска, requeue jobs). F1b — **только + наблюдение + алерт** (L0 reactive, эпик §9). Авто-фиксы — домен D1 (отдельные задачи). +- **Grafana / Prometheus / TSDB / дашборд-UI / исторические графики** (C-3 — тонкий стек). +- **Изменение `/metrics` орка** (контракт F1a/ORCH-099 — данность; sidecar — потребитель). Если + обнаружится нехватка поля — это отдельная задача-расширение F1a, не часть F1b. +- **Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схемы БД орка** — sidecar их не + касается (он вне процесса орка). +- **Журнал уроков (F2)** — отдельная задача; F1b не пишет в БД орка. +- **Второе внешнее плечо мониторинга (L2)** — сознательно отложено (C-2). + +## 3. Заинтересованные стороны +- **Заказчик / приёмка:** Слава (зафиксировал архитектурные рамки 09.06). +- **Постановщик / ведение:** Стрим. +- **Затрагивает:** операторов платформы (получатели алертов), все проекты в общем прод-инстансе + (enduro-trails и пр.) — sidecar повышает наблюдаемость их общей инфраструктуры, **не вмешиваясь**. +- **Исполнители конвейера:** architect (стек, формат хранения порогов, владелец диск-алерта), + developer, reviewer, tester, deployer. + +## 4. Бизнес-требования (BR) + +- **BR-1 (отдельный контейнер).** Sidecar собирается в отдельный образ (`watchdog/Dockerfile`) и + работает как сервис `orchestrator-watchdog` в `docker-compose.yml` — отдельный процесс/память/ + рестарт, **НЕ внутри процесса орка**. +- **BR-2 (сбор сырья орка).** На каждом тике sidecar делает `GET /metrics` орка по HTTP и + разбирает версионированный конверт (`schema_version`/`stages`/`queue`/`agents`/`cost`), **толерантно + к неизвестным/отсутствующим полям** (контракт F1a — additive, версия не растёт на добавление поля). +- **BR-3 (сбор хоста).** Sidecar измеряет хост: заполнение диска (% и, где доступно, inode), память, + CPU — по смонтированным хост-путям/интерфейсам, доступным контейнеру. +- **BR-4 (сбор контейнеров).** Sidecar читает состояние контейнеров через `docker.sock` + (**read-only mount**): различает Up / healthy / restarting / exited / unhealthy. Минимум — статус + ключевых контейнеров платформы (включая сам `orchestrator`). +- **BR-5 (пинг зависимостей).** Sidecar периодически проверяет доступность внешних зависимостей — + Plane, Gitea, Anthropic (лёгкий health/ping, короткий таймаут) — и алертит при недоступности. +- **BR-6 (пороговый алертинг).** При **пересечении порога** сигналом (диск≥порог, память, + agent-завис >N мин, job-failed, застрявшая стадия, контейнер-down/unhealthy, зависимость + недоступна) sidecar шлёт **ровно один** Telegram-алерт. +- **BR-7 (орк-down = тревога).** Если `GET /metrics` орка **не отвечает** (таймаут/connection + refused/5xx) — sidecar шлёт алерт «орк не отвечает». Это **главный** сценарий ценности: + наблюдатель жив, наблюдаемый лёг. +- **BR-8 (свой Telegram-канал).** Алерты идут через **независимый** транспорт sidecar — собственные + bot-токен и chat-id из `.env`, БЕЗ обращения к коду/функциям/токену орка (иначе падение орка + утянуло бы и алерт-канал — нарушение C-1). +- **BR-9 (дедуп / throttle / recovery).** Повторное нахождение метрики за порогом не флаппит: один + алерт на пересечение + анти-спам cooldown между повторами + **recovery-сообщение** при возврате + метрики в норму. Поведение — по образцу `disk_watchdog` (ORCH-063): чистая решающая функция + `(value, threshold, prev_state, now, cooldown) → alert | realert | recovery | none`. +- **BR-10 (нет дубля диск-алерта).** Диск уже алертит `disk_watchdog` ORCH-063 (порог 85%, через + Telegram орка). F1b **НЕ должен** порождать второй диск-алерт на то же событие. **Владельца + диск-алерта (sidecar vs внутренний `disk_watchdog`) выбирает архитектор** — BRD лишь фиксирует + требование «один диск-алерт на событие, без дублирования». + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 (изоляция / резилентность).** Падение/зависание/рестарт орка **НЕ роняет** sidecar + (доказывается: орк down → sidecar продолжает тикать и шлёт алерт). Обратное тоже: sidecar — чисто + наблюдатель, его падение не влияет на конвейер. +- **NFR-2 (тонкость).** Контейнер лёгкий: предсказуемо малое потребление памяти (хост впритык — + 171Mi free). Конкретный бюджет памяти и `mem_limit` — решение архитектора; BRD требует «в разумных + пределах, измеримо». **НЕ Grafana/Prometheus.** +- **NFR-3 (never-raise).** Любая ошибка сбора/парсинга/сети/отправки — best-effort: один битый + источник деградирует один сигнал, не роняет тик; ошибка тика не роняет демон. По образцу + `disk_watchdog` / `metrics` (три уровня never-raise: per-source, per-tick, per-send). +- **NFR-4 (безопасность self-hosting).** Sidecar **только читает и шлёт Telegram** — НИКОГДА не + трогает диск/контейнеры/прод, не рестартит, не пишет в `docker.sock` (mount **read-only**), не + пишет в БД орка, не пушит в `main`. Безопасен для общего инстанса (enduro-trails не затронут). +- **NFR-5 (управляемость / обратимость).** Kill-switch (выключить → sidecar инертен/не стартует, + нулевой эффект на орк). Пороги и интервалы конфигурируемы через `.env` (не хардкод). +- **NFR-6 (изоляция контракта).** Sidecar толерантен к версии `/metrics`: неизвестное поле + игнорируется, отсутствие опционального — не падение; рост `schema_version` логируется (предупреждение), + не крэшит. +- **NFR-7 (наблюдаемость самого sidecar).** Стартап/тик/решения логируются достаточно, чтобы по логам + контейнера понять, что sidecar жив и почему (не)сработал алерт. + +## 6. Допущения и ограничения + +- **Зависимость:** F1b **зависит от F1a (ORCH-099)** — читает `GET /metrics`. Контракт `/metrics` + (envelope `schema_version`/`generated_at`/`clk_tck`/`stages`/`queue`/`agents`/`cost`/`enabled`) — + установленный факт, sidecar его потребитель. +- **Сеть:** орк работает `network_mode: host` (порт 8500) → из host-network sidecar `/metrics` + достижим как `http://127.0.0.1:8500/metrics`. Точный сетевой режим sidecar — решение архитектора. +- **`docker.sock`** доступен на хосте `/var/run/docker.sock`; монтируется в sidecar **read-only**. +- **Разовое инфра-действие** (добавить сервис в compose + первый запуск + создать bot/chat watchdog) + выполняется человеком (Слава/Стрим) на хосте — фиксируется в `07-infra-requirements.md`. Дальше код + watchdog катится через конвейер (self-hosting). +- **Стек (Python/Go), формат хранения порогов, владелец диск-алерта** — **зона архитектора** в рамках + C-1…C-3; BRD их не предрешает. +- **Известный принятый риск (C-2):** падёт весь хост/Docker → молчит и sidecar (нет внешнего плеча). +- **Telegram 48ч** и прочие лимиты транспорта — как у орка (best-effort доставка). + +## 7. Критерии успеха + +Sidecar стартует отдельным контейнером, на каждом тике собирает сырьё орка + хост + контейнеры + +зависимости, при пересечении порога шлёт ровно один Telegram-алерт со своего канала (throttle + +recovery), при недоступности орка шлёт «орк не отвечает», и переживает падение орка не падая сам. +Тонкий, с kill-switch и конфигурируемыми порогами. Разовое инфра-действие задокументировано, pytest +зелёный, доки + CHANGELOG обновлены. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски + +- **Дубль диск-алерта** с `disk_watchdog` ORCH-063 (BR-10) — нужно явное решение владельца (архитектор). +- **Шум алертов** (флапп на границе порога) при недостаточном throttle/recovery — закрывается BR-9. +- **Зависимость от `/metrics`:** ложный «орк-down» при сетевой икоте — нужен разумный таймаут/ретрай в + пороге, чтобы единичный transient не флаппил (детали — архитектор/developer). +- **Ресурсы хоста впритык** — sidecar обязан быть лёгким (NFR-2), иначе сам станет частью проблемы. +- **`docker.sock` доступ** — строго read-only; риск привилегий минимизируется mount-режимом (NFR-4). +- Детальный реестр и митигации — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-100/02-trz.md b/docs/work-items/ORCH-100/02-trz.md new file mode 100644 index 0000000..f348bfb --- /dev/null +++ b/docs/work-items/ORCH-100/02-trz.md @@ -0,0 +1,155 @@ +--- +work_item: ORCH-100 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-100 — FND/F1b: sidecar-watchdog (мозг мониторинга, отдельный контейнер) + +Work Item: **ORCH-100** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD (`01-brd.md`) и фактического +> кода. Архитектурное обоснование/решения (выбор стека Python/Go, формат хранения порогов, владелец +> диск-алерта, точная топология сети sidecar, бюджет памяти/`mem_limit`) — **зона архитектора** +> (`06-adr/`). ТЗ фиксирует ТРЕБОВАНИЯ и ограничения, не способ реализации. + +## 1. Сводка изменения + +Добавить **отдельный sidecar-контейнер** `orchestrator-watchdog`, код которого лежит в новой папке +`watchdog/` репозитория орка, а рантайм — изолированный контейнер (свой `watchdog/Dockerfile` + сервис +в `docker-compose.yml`). Sidecar периодически (тик): (1) тянет `GET /metrics` орка; (2) меряет хост +(диск/inode/память/CPU); (3) читает статусы контейнеров через read-only `docker.sock`; (4) пингует +Plane/Gitea/Anthropic. По набору **конфигурируемых порогов** через **чистую решающую функцию** +(образец `disk_watchdog.decide`) принимает решение `alert | realert | recovery | none` с дедупом/ +throttle, и шлёт алерт в **собственный** Telegram-канал (свой токен/chat, независимо от кода орка). +Особый сигнал: `/metrics` не отвечает → алерт «орк не отвечает». Всё — never-raise, под kill-switch, +строго read-only к наблюдаемому (self-hosting-безопасно). + +**Орк-сторона (`src/**`) не меняется**: F1b — потребитель уже существующего `GET /metrics` (F1a, +ORCH-099). `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схема БД орка — **не тронуты**. + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `watchdog/` | **создать** — корень кода sidecar (новая папка в репо орка) | +| `watchdog/Dockerfile` | **создать** — отдельный тонкий образ sidecar (стек — выбор архитектора) | +| `watchdog/` | **создать** — демон/цикл сбора+решения+отправки (имя/структура — архитектор) | +| `watchdog/` | **создать** — сбор: `/metrics` орка (HTTP), хост (диск/inode/память/CPU), контейнеры (`docker.sock` ro), пинг Plane/Gitea/Anthropic | +| `watchdog/` | **создать** — **чистая** решающая функция порога `(value, threshold, prev_state, now, cooldown) → alert\|realert\|recovery\|none` (образец `src/disk_watchdog.py::decide`) | +| `watchdog/` | **создать** — независимый Telegram-транспорт sidecar (свой токен/chat; НЕ импорт `src/notifications.py`) | +| `watchdog/` | **создать** — чтение порогов/интервалов/токенов/kill-switch из env | +| `watchdog/tests/` (или `tests/watchdog/`) | **создать** — pytest на чистые функции (решение/парсинг/детект орк-down); размещение — архитектор | +| `docker-compose.yml` | **изменить** — добавить сервис `orchestrator-watchdog` (build `watchdog/`, restart-policy, read-only `docker.sock`, `mem_limit`, env, kill-switch) | +| `.env.example` | **изменить** — канон: токен/chat watchdog + пороги + интервалы + kill-switch (без секретов) | +| `CHANGELOG.md` | **изменить** — запись о F1b | +| `docs/work-items/ORCH-100/07-infra-requirements.md` | **создать (architect)** — разовое инфра-действие: добавить сервис в compose, создать bot/chat watchdog, первый запуск на хосте | + +> **`src/**` НЕ редактируется.** Если в ходе разработки выяснится нехватка поля в `/metrics` — это +> отдельная задача-расширение F1a (ORCH-099), а не правка в рамках F1b (см. BRD §«Вне объёма»). + +## 3. Функциональные требования + +### FR-1 — Отдельный контейнер sidecar (BR-1, NFR-1) +Sidecar собирается из `watchdog/Dockerfile` в отдельный образ и поднимается сервисом +`orchestrator-watchdog` в `docker-compose.yml`: отдельный процесс/память/рестарт-политика, **НЕ** +внутри процесса орка. `restart: unless-stopped` (или эквивалент) — sidecar самовосстанавливается. + +### FR-2 — Сбор сырья орка (BR-2, NFR-6) +На каждом тике `GET ` (дефолт-достижимость `http://127.0.0.1:8500/metrics` при +host-network; URL конфигурируем). Тело — версионированный конверт F1a: +`{schema_version, generated_at, clk_tck, stages[], queue, agents[], cost, enabled}`. Парсинг +**толерантен**: неизвестные поля игнорируются, отсутствие опционального — не ошибка, рост +`schema_version` логируется (warning), не крэшит. Из конверта извлекаются сигналы для порогов: +agent-liveness (cpu_ticks/runtime → «завис»), застрявшая стадия, job-failed, длина очереди. + +### FR-3 — Детект «орк не отвечает» (BR-7) — главный сигнал +Если `GET /metrics` завершается таймаутом / connection refused / 5xx / нечитаемым телом — это +**отдельный сигнал тревоги** `orchestrator_down`. Проходит через ту же машину порога/дедупа/recovery +(BR-9): один алерт «орк не отвечает», recovery при восстановлении. Единичный transient не должен +немедленно флаппить — порог/таймаут/ретрай подбираются так, чтобы алерт был осмысленным (детали — +архитектор/developer; требование: «не флаппить на одиночной сетевой икоте»). + +### FR-4 — Сбор хоста (BR-3) +Измерять заполнение диска (% и, где доступно, inode), память, CPU по доступным контейнеру +хост-путям/интерфейсам (стдлиб-средствами выбранного стека; **без** тяжёлых агентов). Пути/пороги — +конфигурируемы. **Диск:** см. FR-9 (анти-дубль с ORCH-063). + +### FR-5 — Сбор контейнеров (BR-4, NFR-4) +Через `docker.sock`, смонтированный **read-only**, читать состояния контейнеров платформы: +различать Up / healthy / restarting / exited / unhealthy. Минимум — статус `orchestrator` (и других +ключевых сервисов). **Только чтение** Docker API (list/inspect) — никаких start/stop/restart/exec. + +### FR-6 — Пинг внешних зависимостей (BR-5) +Периодически проверять доступность Plane / Gitea / Anthropic лёгким запросом (health/ping, короткий +таймаут, never-raise). Недоступность → сигнал для порога. Эндпоинты/таймауты — конфигурируемы. + +### FR-7 — Пороговый алертинг (BR-6, BR-9) +Каждый сигнал проходит через **чистую решающую функцию** (образец `disk_watchdog.decide`): +вход `(value/state, threshold, prev_state, now, cooldown)`, выход `alert | realert | recovery | none`. +Семантика: +- не-alerting & за порогом → **ALERT** (один на пересечение); +- alerting & за порогом & cooldown истёк → **REALERT**; +- alerting & за порогом & в cooldown → **NONE** (анти-спам); +- alerting & вернулось в норму → **RECOVERY**; +- не-alerting & в норме → **NONE**. +Состояние порога (alerting/last_alert_at) — per-signal, in-memory (best-effort; рестарт sidecar +сбрасывает → корректно повторно алертит ещё стоящую проблему, как `disk_watchdog`). Хранилище +состояния/порогов (in-memory vs файл/иное) — **решение архитектора**. + +### FR-8 — Независимый Telegram-транспорт (BR-8, NFR-4) +Отправка через собственный код sidecar (свой ``), читающий **свои** `bot_token`/`chat_id` +из env. **Запрещено** импортировать/вызывать `src/notifications.py` или использовать токен/функции +орка (иначе падение орка утянет алерт-канал). `disable_web_page_preview`/`parse_mode` — по +усмотрению; сообщение содержит суть алерта (сигнал, значение, порог, хост/контейнер). + +### FR-9 — Анти-дубль диск-алерта (BR-10) +Диск уже алертит `disk_watchdog` (ORCH-063, порог 85%, Telegram орка). F1b **не должен** слать +второй диск-алерт на то же событие. **Владельца диск-алерта выбирает архитектор** (варианты: +sidecar становится единственным владельцем и внутренний `disk_watchdog` остаётся как fallback на +случай down-канала орка; ИЛИ sidecar не дублирует диск, оставляя его за ORCH-063). ТЗ фиксирует +инвариант: **на одно событие переполнения диска — не более одного алерта**, решение и его обоснование — +в `06-adr/`. + +### FR-10 — Управляемость (NFR-5) +Kill-switch (env): выключен → sidecar не стартует / инертен, нулевой эффект на орк и конвейер. +Пороги (диск, память, agent-завис N мин, длина очереди, и т.п.), интервал тика, таймауты, cooldown — +из env (`.env.example` — канон). + +### FR-11 — never-raise (NFR-3) +Три уровня: per-source (битый источник деградирует один сигнал, прочие собираются), per-tick (внешний +try/except цикла), per-send (обёрнутая отправка). Демон не падает от ошибки сбора/сети/парсинга. + +## 4. Изменения API + +**Нет** изменений API орка. Sidecar — **клиент** существующего `GET /metrics` (F1a, ORCH-099). Орк +новых эндпоинтов не получает. Sidecar собственного входящего HTTP-API не обязан иметь (опциональный +liveness-эндпоинт самого sidecar — на усмотрение архитектора, вне обязательного объёма). + +## 5. Изменения схемы БД + +**Нет.** Sidecar **не пишет** в БД орка (NFR-4) и не имеет своей БД (тонкий стек, C-3). Состояние +порогов — in-memory best-effort (FR-7). Журнал уроков (F2, БД орка) — отдельная задача, не F1b. + +## 6. Требования к новым/изменённым QG checks + +**Нет.** F1b живёт **вне** процесса орка и **вне** конвейера Quality Gate. `QG_CHECKS` / `check_*` / +`STAGE_TRANSITIONS` — **не тронуты** (по образцу operational-демонов `disk_watchdog`/`reaper`/ +`reconciler`, которые тоже не являются Quality Gate). Sidecar — операционный наблюдатель, не гейт. + +## 7. Совместимость / регресс + +- **Обратная совместимость:** изменения **аддитивны** — новая папка `watchdog/`, новый сервис в + compose, новые ключи в `.env.example`. Существующий орк-контейнер и его поведение — без изменений. +- **Kill-switch:** выключенный sidecar = нулевой эффект (не стартует), полная обратимость (NFR-5). +- **Область раската:** только инфраструктура наблюдения; конвейер всех проектов не затронут + (self-hosting-безопасно, NFR-4). +- **Регресс:** существующий `pytest tests/` остаётся зелёным; новые тесты sidecar добавляются + изолированно (FR — чистые функции тестируемы без контейнера/таймера, образец + `tests/` для `disk_watchdog.decide`). +- **Разовое инфра-предусловие** (не код): добавить сервис в compose + создать bot/chat watchdog + + первый запуск на хосте (Слава/Стрим). Зафиксировать в `07-infra-requirements.md`. Отсутствие + bot/chat watchdog = sidecar не шлёт (fail-safe, логирует), но не падает. diff --git a/docs/work-items/ORCH-100/03-acceptance-criteria.md b/docs/work-items/ORCH-100/03-acceptance-criteria.md new file mode 100644 index 0000000..05c024f --- /dev/null +++ b/docs/work-items/ORCH-100/03-acceptance-criteria.md @@ -0,0 +1,114 @@ +--- +work_item: ORCH-100 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-100 — FND/F1b: sidecar-watchdog + +Work Item: **ORCH-100** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и поведению. + +--- + +## AC-1 — Sidecar стартует отдельным контейнером и собирает все источники + +**Условие:** есть папка `watchdog/` с кодом + `watchdog/Dockerfile`; в `docker-compose.yml` есть +сервис `orchestrator-watchdog`, собираемый из `watchdog/`; запущенный sidecar на тике собирает +сырьё орка (`GET /metrics`) + хост (диск/память/CPU) + контейнеры (`docker.sock`) + пинг зависимостей. +- **PASS:** `watchdog/Dockerfile` существует; сервис `orchestrator-watchdog` объявлен отдельным + сервисом в `docker-compose.yml` (свой build/restart/`mem_limit`, read-only `docker.sock`); код + sidecar реализует все 4 коллектора (метрики орка, хост, контейнеры, зависимости); тик опрашивает + все 4 (подтверждается тестами/логами). +- **FAIL:** мониторинг встроен в процесс орка (`src/**`) / нет отдельного сервиса в compose / отсутствует + любой из 4 коллекторов / `docker.sock` смонтирован НЕ read-only. + +--- + +## AC-2 — Пороговый алерт: один на пересечение + throttle + recovery + орк-down + +**Условие:** при пересечении порога — ровно один Telegram-алерт со **своего** канала sidecar; повтор +в cooldown молчит; возврат в норму шлёт recovery; недоступность `/metrics` орка → алерт «орк не +отвечает». +- **PASS:** чистая решающая функция возвращает `alert | realert | recovery | none` по семантике FR-7 + (тесты TC-01…TC-04 зелёные); алерт идёт через независимый транспорт sidecar (свой токен/chat, БЕЗ + импорта `src/notifications.py`); сценарий `orchestrator_down` (таймаут/refused/5xx) даёт алерт + «орк не отвечает» (TC-05) и recovery при восстановлении. +- **FAIL:** флапп (>1 алерта на одно пересечение без cooldown) / нет recovery / алерт шлётся через + код/токен орка / `orchestrator_down` не детектируется или не алертит. + +--- + +## AC-3 — Изоляция: падение орка не роняет sidecar + +**Условие:** орк недоступен/упал → sidecar продолжает работать и репортит проблему. +- **PASS:** при недоступном `/metrics` (мок таймаута/refused) тик sidecar не падает, проходит до конца, + формирует алерт `orchestrator_down` (TC-05, TC-08); демон never-raise на трёх уровнях (per-source/ + per-tick/per-send) — ошибка одного источника не валит тик, ошибка тика не валит демон (TC-06). +- **FAIL:** исключение в коллекторе/отправке роняет тик или демон / недоступность орка приводит к + падению/остановке sidecar. + +--- + +## AC-4 — Тонкость, kill-switch, конфигурируемые пороги + +**Условие:** контейнер лёгкий; есть kill-switch; пороги/интервалы конфигурируемы через env. +- **PASS:** `docker-compose.yml` задаёт ограничение памяти sidecar (`mem_limit`/эквивалент) в разумных + пределах (НЕ Grafana/Prometheus-стек); kill-switch (env) при выключении → sidecar не стартует/инертен, + нулевой эффект на орк (TC-07); пороги (диск/память/agent-завис N мин/очередь и т.п.), интервал, + таймауты, cooldown читаются из env; `.env.example` содержит токен/chat watchdog + все пороги/интервалы + (канон, без реальных секретов). +- **FAIL:** нет `mem_limit` / тянется Grafana/Prometheus / нет kill-switch или он не отключает sidecar / + пороги захардкожены / `.env.example` не обновлён или содержит реальный секрет. + +--- + +## AC-5 — Анти-дубль диск-алерта (согласовано с ORCH-063) + +**Условие:** на одно событие переполнения диска — не более одного алерта; владелец зафиксирован в ADR. +- **PASS:** в `06-adr/` зафиксировано решение о владельце диск-алерта (sidecar vs внутренний + `disk_watchdog` ORCH-063); реализация не порождает два алерта на то же событие переполнения; выбор + обоснован. +- **FAIL:** диск алертится дважды (и sidecar, и `disk_watchdog`) на одно событие / решение о владельце + не задокументировано. + +--- + +## AC-6 — Безопасность self-hosting (только чтение/алерт) + +**Условие:** sidecar ничего не мутирует в наблюдаемой системе. +- **PASS:** код sidecar не содержит вызовов записи/управления — нет start/stop/restart/exec контейнеров, + нет записи в `docker.sock` (mount read-only), нет записи в БД орка, нет операций с диском хоста + (кроме чтения заполнения), нет push в `main`. Подтверждается ревью кода + статической проверкой + (TC-09: docker-клиент используется только для list/inspect). +- **FAIL:** sidecar выполняет любое мутирующее действие над контейнерами/диском/БД/прод-инстансом. + +--- + +## AC-7 — Разовое инфра-действие задокументировано; pytest зелёный; доки+CHANGELOG + +**Условие:** инфра-предусловие описано; тесты проходят; документация обновлена. +- **PASS:** `07-infra-requirements.md` описывает разовое действие (добавить сервис в compose, создать + bot/chat watchdog, первый запуск на хосте); `pytest` (полный `tests/` + тесты sidecar) зелёный; + `CHANGELOG.md` содержит запись F1b; релевантные доки (CLAUDE.md/README при необходимости) обновлены. +- **FAIL:** нет `07-infra-requirements.md` / падают тесты / нет записи в CHANGELOG / функционал добавлен + без обновления документации. + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1/2/3/4/5 · FR-1/2/4/5/6 · NFR-4 | +| AC-2 | BR-6/7/8/9 · FR-3/7/8 | +| AC-3 | NFR-1/3 · FR-3/11 | +| AC-4 | NFR-2/5 · FR-10 | +| AC-5 | BR-10 · FR-9 | +| AC-6 | NFR-4 · FR-5/8 | +| AC-7 | BR (доки) · NFR-7 · процессные правила агентов | diff --git a/docs/work-items/ORCH-100/04-test-plan.yaml b/docs/work-items/ORCH-100/04-test-plan.yaml new file mode 100644 index 0000000..2eb4eb7 --- /dev/null +++ b/docs/work-items/ORCH-100/04-test-plan.yaml @@ -0,0 +1,108 @@ +work_item: ORCH-100 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +title: "FND/F1b sidecar-watchdog — пороговый алертинг, орк-down, изоляция, self-hosting safety" +framework: pytest +scope: > + Покрывает чистую логику sidecar (решающая функция порога, парсинг конверта /metrics, + детект orchestrator-down, never-raise) и структурно-инфраструктурные инварианты (отдельный + сервис в compose, read-only docker.sock, независимый Telegram-транспорт, kill-switch, + анти-дубль диск-алерта). ВНЕ покрытия: реальный Telegram-API, живой docker.sock, живой + хост-хост-стек (мокаются); сетевые коллекторы тестируются на моках, не на боевых Plane/Gitea/ + Anthropic. Стек sidecar (Python/Go) и точное размещение тестов выбирает архитектор — при Python + тесты идут в общий pytest; если архитектор выберет Go, набор тест-кейсов переносится на go test + 1:1 по смыслу (решение/парсинг/детект/never-raise остаются обязательными). +notes: > + Образец чистой решающей функции и её тестов — src/disk_watchdog.py::decide и его тесты в tests/. + Все коллекторы/транспорт мокаются (никаких боевых сетевых/docker-вызовов в CI). Полный регресс + tests/ орка должен оставаться зелёным (src/** не меняется). Тесты sidecar изолированы и не требуют + поднятого контейнера/таймера. Пути модулей watchdog/* — ориентировочные; финальные имена задаёт + архитектор/developer, id и смысл тест-кейсов сохраняются. + +tests: + - id: TC-01 + type: unit + description: "Решающая функция: not-alerting & value>=threshold -> ALERT (один на пересечение порога)" + module: watchdog/tests/test_decision.py + expected: PASS + + - id: TC-02 + type: unit + description: "Решающая функция: alerting & still>=threshold & cooldown НЕ истёк -> NONE (анти-спам throttle)" + module: watchdog/tests/test_decision.py + expected: PASS + + - id: TC-03 + type: unit + description: "Решающая функция: alerting & still>=threshold & cooldown истёк -> REALERT (повторный алерт)" + module: watchdog/tests/test_decision.py + expected: PASS + + - id: TC-04 + type: unit + description: "Решающая функция: alerting & value вернулось ниже порога -> RECOVERY (recovery-сообщение)" + module: watchdog/tests/test_decision.py + expected: PASS + + - id: TC-05 + type: unit + description: "Детект orchestrator-down: /metrics таймаут/connection-refused/5xx -> сигнал orchestrator_down -> ALERT «орк не отвечает»" + module: watchdog/tests/test_orch_down.py + expected: PASS + + - id: TC-06 + type: unit + description: "never-raise: исключение в одном коллекторе (хост/контейнеры/деп) деградирует один сигнал, тик доходит до конца и собирает остальные" + module: watchdog/tests/test_never_raise.py + expected: PASS + + - id: TC-07 + type: unit + description: "Kill-switch: при выключенном флаге sidecar инертен/не стартует тик; пороги/интервалы/таймауты читаются из env (не хардкод)" + module: watchdog/tests/test_config_killswitch.py + expected: PASS + + - id: TC-08 + type: integration + description: "Полный тик при недоступном орке (мок /metrics down): тик не падает, собирает хост/контейнеры/деп, формирует ровно один алерт orchestrator_down, recovery при восстановлении" + module: watchdog/tests/test_tick_orch_down_integration.py + expected: PASS + + - id: TC-09 + type: unit + description: "Self-hosting safety: docker-клиент используется только для чтения (list/inspect); нет вызовов start/stop/restart/exec/записи (статическая/мок-проверка)" + module: watchdog/tests/test_docker_readonly.py + expected: PASS + + - id: TC-10 + type: unit + description: "Независимый транспорт: алерт-отправка использует СВОИ токен/chat sidecar из env и НЕ импортирует src/notifications.py / код орка" + module: watchdog/tests/test_notify_isolation.py + expected: PASS + + - id: TC-11 + type: unit + description: "Толерантность к контракту /metrics: неизвестное поле игнорируется, отсутствие опционального не падает, рост schema_version логируется (warning) без крэша" + module: watchdog/tests/test_metrics_parse.py + expected: PASS + + - id: TC-12 + type: integration + description: "Compose-инвариант: orchestrator-watchdog объявлен отдельным сервисом (свой build watchdog/, restart, mem_limit) с docker.sock в режиме :ro" + module: watchdog/tests/test_compose_service.py + expected: PASS + + - id: TC-13 + type: unit + description: "Анти-дубль диск-алерта: согласно решению ADR владельца — sidecar не порождает второй диск-алерт на то же событие переполнения (по образцу взаимодействия с ORCH-063)" + module: watchdog/tests/test_disk_alert_dedup.py + expected: PASS + + - id: TC-14 + type: unit + description: "Регресс орка: полный pytest tests/ зелёный — src/** не изменён, /metrics-контракт (ORCH-099) не сломан" + module: tests/ + expected: PASS From 1c08b3f62ad07ca1bff2e5132ee4da9d1a31d12e Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 08:49:58 +0300 Subject: [PATCH 03/10] architect(ET): auto-commit from architect run_id=565 --- docs/architecture/README.md | 48 +++ .../adr/adr-0033-sidecar-watchdog.md | 85 +++++ .../06-adr/ADR-001-sidecar-watchdog.md | 304 ++++++++++++++++++ .../ORCH-100/07-infra-requirements.md | 93 ++++++ .../ORCH-100/08-data-requirements.md | 40 +++ docs/work-items/ORCH-100/10-tech-risks.md | 44 +++ 6 files changed, 614 insertions(+) create mode 100644 docs/architecture/adr/adr-0033-sidecar-watchdog.md create mode 100644 docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md create mode 100644 docs/work-items/ORCH-100/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-100/08-data-requirements.md create mode 100644 docs/work-items/ORCH-100/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3ef112d..a54c24c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -20,6 +20,7 @@ - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость). - **FS ownership detect** (`src/fs_normalize.py`, ORCH-057 — [adr-0031](adr/adr-0031-legacy-ownership-normalization.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`preflight`), закрывает пробел ORCH-040: при миграции на `user: "1000:1000"` legacy `root:root` файлы в `/repos` ломали создание worktree под uid 1000 (`ensure_worktree` → сырой `fatal: … Permission denied`, агент не стартовал). Три слоя: (1) **D1** — `src/git_worktree.py::ensure_worktree` классифицирует класс «нет прав» (`Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/`EPERM`) и поднимает actionable `RuntimeError` с причиной + лечащей командой (не-прав-ошибки сохраняют прежний контракт — меняется только формулировка, не факт сбоя); (2) **D2** — `scan_ownership(roots, target_uid=os.getuid())` обходит `/repos/_wt`, `/.git/{objects,worktrees}`, `data/runs` с ранним выходом при первом `st_uid != target_uid` + TTL-кэш; (3) **D3** — best-effort вызов на старте `main.lifespan` → WARNING + Telegram при mismatch (claim **НЕ** блокируется — внятный ранний отказ даёт D1 в точке launch, знающей repo; preflight-блок отвергнут как repo-слепой → регресс enduro). Опц. `normalize()` chown'ит только при `CAP_CHOWN` (под uid 1000 — no-op; init-контейнер/root-entrypoint отвергнуты — реинтродукция root-контекста + self-deploy compose). Фактическая нормализация = **операторская процедура** под root на хосте (`INFRA.md` «Миграция uid»). Условность `applies(repo)` first: `fs_normalize_enabled` (kill-switch) + `fs_normalize_repos` (CSV, пусто → self-hosting only). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST /fs-normalize/check`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`. - **Metrics endpoint** (`src/metrics.py` + `GET /metrics`, ORCH-099 — [adr-0030](adr/adr-0030-metrics-endpoint.md)) — лёгкий **read-only** leaf-сборщик (`build_metrics() -> dict`, never-raise по разделам, паттерн `serial_gate.snapshot()`) + тонкий эндпоинт (стиль `GET /queue`). Отдаёт JSON-«сырьё» о самом орке (стадии задач / очередь jobs / agent-liveness / стоимость-токены) как **стабильный машинный контракт для sidecar F1b** (`watchdog/`, отдельная задача — наблюдатель отделён от наблюдаемого). Только чтение существующих `tasks`/`jobs`/`agent_runs` + in-memory-снапшотов (`worker.breaker`); два read-only helper'а в `db.py` (`get_running_agents`/`agent_cost_totals`). Логику мониторинга (пороги/алерты/история/Telegram) НЕ несёт — это F1b. Контракт ниже (§ «Сырьё-эндпоинт `/metrics`»). Kill-switch `metrics_endpoint_enabled` (дефолт `True`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. +- **Sidecar-watchdog F1b** (`watchdog/` + сервис `orchestrator-watchdog`, ORCH-100 — [adr-0033](adr/adr-0033-sidecar-watchdog.md)) — **мозг мониторинга в ОТДЕЛЬНОМ контейнере** (наблюдатель отделён от наблюдаемого, C-1): код в репо орка (`watchdog/`), рантайм — свой образ (`watchdog/Dockerfile`, `python:3.12-slim`, **stdlib-only**) + сервис в `docker-compose.yml` (`network_mode: host`, read-only `docker.sock`, `mem_limit: 128m`). На каждом тике собирает 4 источника: `GET /metrics` орка (F1a/ORCH-099), хост (диск/inode/память/CPU, stdlib), статусы контейнеров через read-only `docker.sock` (GET-only, без `docker` SDK), пинг Plane/Gitea/Anthropic. Каждый сигнал → **обобщённая чистая** `decide(signal_active, prev, now, cooldown)` (генерализация `disk_watchdog.decide_action`, per-signal in-memory `AlertState`) → алерт в **собственный** Telegram-канал sidecar (`WATCHDOG_TG_*`, **НЕ** импорт `src/notifications.py`). Особый сигнал `orch_down` — `/metrics` не отвечает (наблюдатель жив, наблюдаемый лёг). Диск: штатные 85% остаются за `disk_watchdog` (ORCH-063, нулевой дубль), sidecar — `orch_down` + opt-in потолок 97% (default off). never-raise, kill-switch `WATCHDOG_ENABLED`, строго read-only к наблюдаемому; `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД орка — не тронуты. Подробнее ниже (§ «Sidecar-watchdog F1b»). Детали — `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`. ## Сырьё-эндпоинт `/metrics` для sidecar (ORCH-099 — design) @@ -73,6 +74,53 @@ F1b (рамка C-1: наблюдатель отделён от наблюдае Подробнее: [adr-0030](adr/adr-0030-metrics-endpoint.md), детально — `docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md`. +## Sidecar-watchdog F1b (ORCH-100 — design) + +**Вторая половина пары наблюдаемости.** F1a (ORCH-099) отдаёт сырьё через `GET /metrics`; F1b — мозг, +который это сырьё читает, дополняет внешними сигналами и превращает в алерты. Ключевая рамка +заказчика — **наблюдатель отделён от наблюдаемого** (C-1): частичные стражи (`disk_watchdog`/`reaper`/ +`reconciler`) живут ВНУТРИ процесса орка и лягут вместе с ним; sidecar в отдельном контейнере +переживает падение орка и делает наблюдателя **громче** в инцидент. + +- **Рантайм:** код в `watchdog/` (репо орка), но **отдельный контейнер** `orchestrator-watchdog` + (свой `watchdog/Dockerfile`, `python:3.12-slim`, **stdlib-only** — без сторонних зависимостей, + C-3 «тонкий стек, НЕ Grafana/Prometheus»). `network_mode: host` → `/metrics` достижим как + `http://127.0.0.1:8500/metrics`; `docker.sock` смонтирован **read-only**; `mem_limit: 128m`; + `restart: unless-stopped`. +- **4 коллектора на тик:** (a) `GET /metrics` орка (толерантный парсинг конверта F1a — неизвестные + ключи игнор, рост `schema_version` → warning); (b) хост — диск (`shutil.disk_usage`)/inode/память + (`/proc/meminfo`)/CPU; (c) контейнеры через read-only `docker.sock` — **только** GET list/inspect + (Up/healthy/restarting/exited/unhealthy), без `docker` SDK; (d) пинг Plane/Gitea/Anthropic. +- **Решение — обобщённая чистая функция** `decide(signal_active, prev, now, cooldown) -> alert | + realert | recovery | none` (строгая генерализация `src/disk_watchdog.py::decide_action`; + per-signal in-memory `AlertState`, рестарт → корректный повторный алерт стоящей проблемы). Реестр + сигналов: `orch_down` (K подряд неудачных опросов), `host_mem`, `host_disk_crit` (opt-in потолок), + `agent_hung` (доля CPU из Δ`cpu_ticks`/`clk_tck`/Δ`generated_at` < floor при растущем `runtime_s` — + sidecar stateful-арбитр), `stage_stuck` (`age_in_stage_s`), `job_failed` (edge), `queue_depth`, + `container_down` (per name), `dep_down` (per name). Пороги/интервалы/URL — из env (`WATCHDOG_*`). +- **`orch_down` — главный сигнал:** `/metrics` не отвечает (таймаут/refused/5xx/нечитаемо) → алерт + «орк не отвечает» через ту же машину порога/дедупа/recovery. Наблюдатель жив, наблюдаемый лёг. +- **Независимый Telegram-канал:** свои `WATCHDOG_TG_BOT_TOKEN`/`WATCHDOG_TG_CHAT_ID`; **запрещено** + импортировать `src/notifications.py` или использовать токен орка (иначе падение орка утянуло бы и + алерт-канал — нарушение C-1). +- **Владелец диск-алерта (BR-10, ADR-001 D6):** штатные 85% — ЕДИНСТВЕННО за внутренним + `disk_watchdog` (ORCH-063, канал орка) ⇒ **нулевой дубль по построению**; sidecar покрывает провал + «орк+disk_watchdog мертвы» через `orch_down`, плюс **opt-in** независимый критический потолок + `host_disk_crit` (97%, `WATCHDOG_DISK_CRIT_ENABLED=false` по умолчанию) — другое событие/канал. +- **Гарантии:** never-raise (per-source/per-tick/per-send); kill-switch `WATCHDOG_ENABLED=false` → + демон инертен (idle-loop, нулевой эффект на орк); строго read-only к наблюдаемому (нет + start/stop/restart/exec/записи в `docker.sock`/БД/`main`) ⇒ self-hosting-безопасно (enduro не + затронут). `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД орка — **не тронуты** + (F1b вне процесса орка и вне конвейера QG — как `disk_watchdog`/`reaper`/`reconciler`). Деплой + sidecar НЕ рестартит прод-контейнер `orchestrator`; прод-выкат — через staging-гейт (8501). +- **Инфра-предусловие (разовое, человек):** добавить сервис в compose, создать bot/chat watchdog, + смонтировать `docker.sock` `:ro` + хост-пути, первый запуск на хосте — + `docs/work-items/ORCH-100/07-infra-requirements.md`. + +Подробнее: [adr-0033](adr/adr-0033-sidecar-watchdog.md), детально — +`docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`, +`docs/work-items/ORCH-100/07-infra-requirements.md`. + ## Конвейер и Quality Gates ``` diff --git a/docs/architecture/adr/adr-0033-sidecar-watchdog.md b/docs/architecture/adr/adr-0033-sidecar-watchdog.md new file mode 100644 index 0000000..d2fd36d --- /dev/null +++ b/docs/architecture/adr/adr-0033-sidecar-watchdog.md @@ -0,0 +1,85 @@ +--- +work_item: ORCH-100 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# adr-0033: Sidecar-watchdog F1b — мозг мониторинга в отдельном контейнере + +- **Статус:** proposed +- **Дата:** 2026-06-10 +- **Задача:** ORCH-100 (FND/F1b) +- **Детальный ADR:** `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md` +- **Парный ADR:** `adr-0030` (F1a `/metrics` — источник сырья) + +## Контекст +Домен 0 «Фундамент» эпика автономного саморазвития, рамка наблюдаемости заказчика: **наблюдатель +отделён от наблюдаемого**. F1a (adr-0030) отдаёт read-only `GET /metrics` — **только сырьё**. F1b — +**мозг**: читает сырьё, дополняет внешними сигналами (хост/контейнеры/зависимости), решает по порогам, +алертит. Частичные стражи (`disk_watchdog`/`reaper`/`reconciler`) живут ВНУТРИ процесса орка — орк +завис/упал ⇒ они мертвы, платформа слепа в критический момент. Рамки: C-1 (отдельный контейнер, код в +`watchdog/`), C-2 (без внешнего плеча — принятый риск), C-3 (тонкий стек, НЕ Grafana/Prometheus; хост +впритык). Критический инвариант: орк лёг ⇒ `/metrics` недоступен = **сам сигнал тревоги**. + +## Решение +Новая папка `watchdog/` — **тонкий Python-3.12-stdlib демон** (без сторонних зависимостей), отдельный +образ `watchdog/Dockerfile` + сервис `orchestrator-watchdog` в `docker-compose.yml` (`network_mode: +host`, read-only `docker.sock`, `mem_limit: 128m`, `restart: unless-stopped`). Тик: (1) `GET /metrics`; +(2) хост (диск/inode/память/CPU, stdlib); (3) статусы контейнеров через read-only `docker.sock` +(GET-only — без `docker` SDK); (4) пинг Plane/Gitea/Anthropic. Сигналы проходят через **обобщённую +чистую** `decide(signal_active, prev, now, cooldown) -> alert|realert|recovery|none` (генерализация +`disk_watchdog.decide_action`; per-signal in-memory `AlertState`). Алерт — в **собственный** Telegram- +канал sidecar (свои `WATCHDOG_TG_*`; **НЕ** импорт `src/notifications.py`). Особый сигнал — `/metrics` +не отвечает → `orch_down`. Всё never-raise (per-source/per-tick/per-send), под kill-switch +`WATCHDOG_ENABLED`, строго read-only к наблюдаемому. **`src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/ +`check_*`/схема БД орка — не тронуты** (F1b вне процесса орка и вне конвейера QG). + +- **Стек** — Python stdlib (`urllib`, `socket`+`http.client` для docker.sock, `shutil.disk_usage`, + `/proc/meminfo`); pytest на чистые функции. Отвергнуты Go / `docker` SDK / Prometheus (C-3). +- **Реестр сигналов** — `orch_down` (K подряд неудачных опросов), `host_mem`/`host_disk_crit`, + `agent_hung` (Δ`cpu_ticks`/`clk_tck`/Δ`generated_at` < floor при растущем `runtime_s`; нужно 2 + опроса — sidecar stateful-арбитр), `stage_stuck` (`age_in_stage_s`), `job_failed` (edge), + `queue_depth`, `container_down` (per name), `dep_down` (per name). Пороги/интервалы/URL — из env. +- **Владелец диск-алерта (BR-10)** — штатные 85% остаются за внутренним `disk_watchdog` (ORCH-063, + канал орка) ⇒ **нулевой дубль по построению**; sidecar покрывает провал «орк+disk_watchdog мертвы» + через `orch_down`, плюс **opt-in** (default off) независимый критический потолок `host_disk_crit` + (97%) — другое событие/канал, не повтор 85%. +- **Толерантность контракта** — неизвестные ключи `/metrics` игнорируются, отсутствие опционального не + ошибка, рост `schema_version` → warning (зеркало аддитивной политики adr-0030). +- **Kill-switch** `WATCHDOG_ENABLED=false` → демон инертен (idle-loop, не exit) ⇒ нулевой эффект. + +## Альтернативы +- **Go / `docker` SDK / `requests`** — отклонено: вес/вторая цепочка против C-3 и консистентности с + `disk_watchdog`. +- **Prometheus/Grafana/TSDB** — отклонено: прямой запрет C-3. +- **Sidecar — единственный владелец диска** — отклонено: потеря покрытия, когда сам sidecar/Docker + недоступен; выбрана связка primary `disk_watchdog` + opt-in ceiling. +- **Push из орка в sidecar** — отклонено: зависший орк не пушит; pull падает = сам сигнал `orch_down`. +- **bridge + `host.docker.internal`** — отклонено: на Linux ненадёжно; `network_mode: host` проще. +- **Своя БД/файл порогов** — отклонено: C-3; in-memory best-effort достаточно (как `disk_watchdog`). + +## Последствия +- Внешний мозг мониторинга переживает падение орка; `orch_down` делает наблюдателя громче в инцидент. +- Строго read-only + независимый канал + never-raise ⇒ self-hosting-безопасно (enduro не затронут); + падение sidecar не влияет на конвейер. +- Аддитивно/обратимо: `src/**`/гейты/схема байт-в-байт; kill-switch → нулевая регрессия; дубль диска + исключён структурно. +- Плата: новый контейнер на впритык-хосте (`mem_limit: 128m` + замер RSS на staging обязательны); + C-2 (падёт хост → молчит и sidecar); новая поверхность совместимости `/metrics`↔F1b (толерантный + парсинг + единый репо контракта); CPU-liveness Linux-специфичен. +- **Топология** меняется (новый контейнер) → `07-infra-requirements.md`; **схема БД** не меняется → + 08 = N/A. Новый компонент + контейнер + канал → `arch:major-change`; прод-выкат через staging-гейт + (8501), деплой sidecar НЕ рестартит прод-контейнер. +- **Откат:** не запускать сервис / `WATCHDOG_ENABLED=false` (мгновенный) или удаление `watchdog/` + + сервиса + env — без следов в БД/схеме. + +## Связи +adr-0030 (F1a `/metrics` — парный источник сырья; контракт `cpu_ticks`/`clk_tck`/`generated_at`/ +`schema_version`), adr-0024 (`disk_watchdog` — образец решающей функции/never-raise + владелец +диск-алерта), adr-0025 (build-cache-pruner — паттерн «вторая половина»), adr-0017 (serial_gate — +leaf `snapshot()`/never-raise), adr-0011 (job-reaper — pid/liveness-семантика). Прямой источник — +**F1a** (`GET /metrics`); F1b — его потребитель. + diff --git a/docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md b/docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md new file mode 100644 index 0000000..6f9b368 --- /dev/null +++ b/docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md @@ -0,0 +1,304 @@ +--- +work_item: ORCH-100 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# ADR-001: Sidecar-watchdog F1b — мозг мониторинга в отдельном контейнере + +Work Item: **ORCH-100** — FND/F1b: sidecar-watchdog (мозг мониторинга, отдельный контейнер) +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0033-sidecar-watchdog.md`** (решение +кросс-каттинговое — новый компонент наблюдаемости + новый рантайм-контейнер + новый независимый +алерт-канал; парный к adr-0030 F1a). + +## Статус +Proposed + +## Контекст + +F1b — вторая половина пары наблюдаемости домена 0 «Фундамент» эпика автономного саморазвития. **F1a +(ORCH-099, adr-0030)** уже отдаёт лёгкий read-only `GET /metrics` — **только сырьё** (стадии, +очередь, agent-liveness, cost) в версионированном конверте. F1b — **мозг**, который это сырьё читает, +дополняет внешними сигналами (хост, контейнеры, зависимости) и превращает в **алерты**. + +Рамка заказчика (Слава, 09.06) — **установленный факт, не предмет переизобретения** (BRD §1): +- **C-1 / C-1б:** наблюдатель ОТДЕЛЁН от наблюдаемого. Код sidecar — в репо орка (`watchdog/`), + рантайм — **ОТДЕЛЬНЫЙ контейнер** (`orchestrator-watchdog`). Изоляция на уровне контейнера. +- **C-2:** без внешнего плеча (один хост; принятый риск — падёт весь хост → молчит и наблюдатель). +- **C-3:** тонкий стек — **НЕ Grafana/Prometheus/TSDB**. Хост впритык (RAM 171Mi free / 7.7Gi, диск 92%). +- **Критический инвариант:** падение/зависание орка делает sidecar **громче**, а не тише — орк лёг ⇒ + `/metrics` недоступен = **сам сигнал тревоги** «орк не отвечает». + +Факты, сверенные с кодом: +- Орк работает `network_mode: host`, порт 8500 (`docker-compose.yml:14`) ⇒ из host-network sidecar + `/metrics` достижим как `http://127.0.0.1:8500/metrics`. +- `docker.sock` на хосте `/var/run/docker.sock`, уже монтируется в орк (`docker-compose.yml:18`). +- `src/disk_watchdog.py::decide_action(used_pct, threshold, prev, now, realert_s)` — эталонная + чистая решающая функция `alert | realert | recovery | none` + `PathAlertState` (in-memory + анти-спам) + трёхуровневый never-raise (per-path / per-tick / per-send). BRD §BR-9 прямо предписывает + её как образец. +- Диск уже алертит `disk_watchdog` (ORCH-063) на 85% **через Telegram орка** — потенциальный дубль + (BR-10), требует явного выбора владельца. +- `/metrics`-конверт (adr-0030 D2): `schema_version`/`generated_at`/`clk_tck`/`stages`/`queue`/ + `agents`/`cost`/`enabled`; CPU-сырьё — `cpu_ticks` (utime+stime из `/proc`), орк **дельту не считает** + (stateless) — арбитр «жив/завис» это **F1b** (sidecar считает долю CPU по двум опросам). + +«Как есть» не годится: частичные стражи (`disk_watchdog`/`reaper`/`reconciler`) живут **ВНУТРИ +процесса орка** — зависнет/упадёт орк, лягут и они, и платформа слепа именно в критический момент. + +## Решение + +### Сводка + +Новая папка `watchdog/` в репо орка — **тонкий Python-3.12-stdlib демон** (никаких сторонних +зависимостей), собираемый в отдельный образ (`watchdog/Dockerfile`) и поднимаемый сервисом +`orchestrator-watchdog` в `docker-compose.yml` (свой процесс/память/рестарт, `network_mode: host`, +read-only `docker.sock`). На каждом тике: (1) `GET /metrics` орка; (2) хост (диск/inode/память/CPU); +(3) статусы контейнеров через read-only `docker.sock`; (4) пинг Plane/Gitea/Anthropic. Каждый сигнал +проходит через **обобщённую чистую решающую функцию** (генерализация `disk_watchdog.decide_action`) с +per-signal in-memory дедупом/throttle/recovery и шлёт алерт в **собственный** Telegram-канал sidecar. +Особый сигнал — `/metrics` не отвечает → `orchestrator_down`. Всё never-raise, под kill-switch, +строго read-only к наблюдаемому. **`src/**` не меняется** — F1b потребитель `/metrics`; +`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД орка — **не тронуты**. + +### D1 — Стек: Python 3.12 stdlib-only, отдельный тонкий образ (BR-1, NFR-2, C-3) + +**Решение: Python 3.12 + только стандартная библиотека** на базе `python:3.12-slim`. +- `urllib.request` — HTTP к `/metrics` и пинги зависимостей (короткие таймауты). +- `docker.sock` — **сырой HTTP-over-unix-socket** через stdlib (`socket.AF_UNIX` + + `http.client.HTTPConnection`-подкласс), БЕЗ pip-пакета `docker`. Только `GET /containers/json` и + `GET /containers//json` ⇒ read-only **по построению** (нет ни одного мутирующего вызова). +- Хост-метрики — `shutil.disk_usage` (как `disk_watchdog`), `/proc/meminfo`, `/proc/loadavg` / + `os.getloadavg` — stdlib, без тяжёлых агентов. +- Telegram — `urllib` POST на `api.telegram.org`. +- Тесты — `pytest` на чистые функции (решение/парсинг конверта/детект down), как `disk_watchdog.decide`. + +Обоснование: BRD §BR-9 фиксирует `disk_watchdog.decide` как образец — Python даёт почти дословный +перенос паттерна, переиспользует экспертизу команды и pytest, держит образ тонким (stdlib-only ⇒ нет +дерева зависимостей). **Отвергнуто:** Go (вторая цепочка инструментов/языка ради ~десятков МБ RSS — +не оправдано на фоне C-1-консистентности с `disk_watchdog`); `docker` SDK / `requests` / `httpx` +(вес и поверхность зависимостей против C-3); Prometheus/Grafana/TSDB (прямой запрет C-3). + +Привязка: BR-1, NFR-2, FR-1, AC-1, AC-4. + +### D2 — Топология контейнера: `network_mode: host` + read-only docker.sock + `mem_limit` (BR-1/3/4, NFR-2/4) + +Сервис `orchestrator-watchdog` (`docker-compose.yml`): +- `build: ./watchdog`, `container_name: orchestrator-watchdog`, `restart: unless-stopped` + (самовосстановление, FR-1). +- **`network_mode: host`** — как орк ⇒ `/metrics` достижим как `http://127.0.0.1:8500/metrics` + (дефолт, конфигурируем), и доступны хост-интерфейсы. Отвергнут bridge + `host.docker.internal` + (на Linux ненадёжно, лишняя сложность). +- **`/var/run/docker.sock:/var/run/docker.sock:ro`** — read-only mount (NFR-4, AC-6); даже при + read-only mount код делает **только** GET-запросы (двойная гарантия). +- **Хост-пути для дисковых метрик** — read-only bind тех же путей, что меряет `disk_watchdog` + (`/repos`, `/app/data`/`./data`), `:ro` ⇒ `shutil.disk_usage` видит хост-ФС, но не может писать. +- **`mem_limit: 128m`** (+ `mem_reservation: 32m`) — тонкость измерима и принудительна (NFR-2). + Ожидаемый базовый RSS однопоточного stdlib-демона ~40–60 МБ; 128 МБ — потолок с запасом, но далеко + от Grafana-класса. OOM при превышении = ранний сигнал «sidecar растолстел» (см. 10-tech-risks TR-4). +- `env_file: .env.watchdog` (или общий `.env` с префиксом `WATCHDOG_`; точный файл — деталь + инфра-предусловия 07). Свои токен/chat — **только** у sidecar. +- **Self-hosting:** добавление нового сервиса и `docker compose up -d orchestrator-watchdog` + поднимает ТОЛЬКО watchdog — прод-контейнер `orchestrator` НЕ пересобирается и НЕ рестартится + (отдельный сервис). Это снимает риск «деплой наблюдателя уронил наблюдаемого». + +Привязка: BR-1, BR-3, BR-4, NFR-2, NFR-4, FR-1, FR-4, FR-5, AC-1, AC-4, AC-6. + +### D3 — Структура кода `watchdog/` (NFR-3, NFR-7) + +``` +watchdog/ + Dockerfile # python:3.12-slim, COPY watchdog/, ENTRYPOINT демон + __main__.py # цикл: tick loop, kill-switch, per-tick never-raise, лог старта/тика + config.py # чтение WATCHDOG_* env (пороги/интервалы/токены/URL/kill-switch), дефолты + collectors/ + orch.py # GET /metrics -> распарсенный конверт | сигнал orchestrator_down + host.py # диск (shutil.disk_usage) / inode / память (/proc/meminfo) / CPU (loadavg) + containers.py # docker.sock (ro) GET list/inspect -> статусы Up/healthy/restarting/exited/unhealthy + deps.py # пинг Plane/Gitea/Anthropic (urllib, короткий таймаут) + decision.py # ЧИСТАЯ decide(...) + AlertState (генерализация disk_watchdog) + notify.py # независимый Telegram-транспорт (свой токен/chat; НЕ импорт src/notifications) + tests/ # pytest на чистые функции (или tests/watchdog/ — на усмотрение developer) +``` + +Никакого импорта из `src/**` (иначе падение/рефактор орка утянул бы sidecar — нарушение C-1). +Логирование старта/тика/каждого вердикта в stdout контейнера (NFR-7) — по логам видно, что sidecar +жив и почему (не)сработал алерт. + +Привязка: BR-8, NFR-1, NFR-3, NFR-7, FR-8, FR-11, AC-3. + +### D4 — Обобщённая чистая решающая функция (BR-6, BR-9, FR-7) — образец `disk_watchdog.decide_action` + +`disk_watchdog.decide_action` зашит на `used_pct >= threshold`. Для F1b сигналов много и они +разнотипны (булевы — «орк down», «контейнер unhealthy»; счётчики — «job-failed delta»; пороговые — +«память %», «agent завис N мин»). Поэтому **сравнение выносится наружу**, а функция работает с уже +вычисленным булевым `signal_active`: + +``` +def decide(signal_active: bool, prev: AlertState, now: float, cooldown_s: float) -> str: + # not alerting & active -> ALERT (пересечение порога) + # alerting & active & cooldown ок -> REALERT (повтор) + # alerting & active & в cooldown -> NONE (анти-спам) + # alerting & не active -> RECOVERY (возврат в норму) + # not alerting & не active -> NONE (норма) + +@dataclass +class AlertState: # 1:1 семантика PathAlertState + alerting: bool = False + last_alert_at: float | None = None +``` + +Это **строгая генерализация** disk-варианта (тот же набор исходов, та же cooldown/recovery-семантика, +тот же in-memory best-effort, инъецируемые `now`/`cooldown` для детерминированных тестов). Состояние — +карта `{signal_key -> AlertState}`, где `signal_key` идентифицирует сигнал: скаляр (`"orch_down"`, +`"host_mem"`) или кортеж для пер-сущностных (`("agent_hung", run_id)`, `("container_down", name)`, +`("stage_stuck", work_item)`, `("dep_down", dep_name)`). Рестарт sidecar сбрасывает карту → +корректно повторно алертит ещё стоящую проблему (как `disk_watchdog`; FR-7). + +Привязка: BR-6, BR-9, FR-7, AC-2, TC-01…TC-04. + +### D5 — Реестр сигналов и их пороги (BR-2/3/4/5/6/7, FR-2…FR-7) + +| signal_key | Источник | `signal_active` когда | Порог (env, дефолт) | +|------------|----------|------------------------|----------------------| +| `orch_down` | collectors/orch | K подряд неудачных `/metrics` (таймаут/refused/5xx/нечитаемо) | `WATCHDOG_ORCH_DOWN_TICKS=3` | +| `host_mem` | host | `mem_used_pct >= порог` | `WATCHDOG_MEM_PCT=90` | +| `host_disk_crit` | host | `disk_used_pct >= ceiling` (**opt-in, см. D6**) | `WATCHDOG_DISK_CRIT_PCT=97`, `WATCHDOG_DISK_CRIT_ENABLED=false` | +| `agent_hung` (per run_id) | orch.agents | `runtime_s > N` И доля CPU (Δ`cpu_ticks`/`clk_tck`/Δ`generated_at`) `< floor` | `WATCHDOG_AGENT_HUNG_MIN=20`, `WATCHDOG_AGENT_CPU_FLOOR=0.01` | +| `stage_stuck` (per work_item) | orch.stages | `age_in_stage_s > порог` | `WATCHDOG_STAGE_STUCK_MIN=120` | +| `job_failed` | orch.queue | `counts.failed` вырос с прошлого тика (edge) | — (дельта; алерт на рост) | +| `queue_depth` | orch.queue | `depth >= порог` | `WATCHDOG_QUEUE_DEPTH=20` | +| `container_down` (per name) | containers | статус ∉ {running, healthy} (restarting/exited/unhealthy) | список `WATCHDOG_CONTAINERS=orchestrator` | +| `dep_down` (per name) | deps | пинг неуспешен/таймаут | URL'ы/таймаут из env | + +- **`agent_hung`** требует **двух** опросов (stateful у sidecar) — sidecar хранит предыдущие + `(cpu_ticks, generated_at)` per run_id и считает долю CPU; `cpu_ticks: null` (pid мёртв/не-Linux — + adr-0030 D5) ⇒ сигнал не вычисляется (none), не ложная тревога. +- **`job_failed`** — edge-сигнал (рост счётчика), а не sustained-порог: при росте `failed` → ALERT + один раз; recovery как такового нет (это событие), поэтому состояние сбрасывается сразу после + отправки (alerting=False), чтобы следующий новый фейл снова алертил. +- Все пороги/интервалы/URL/таймауты/cooldown — из env (FR-10), канон в `.env.example`. + +Привязка: BR-2…BR-7, FR-2…FR-7, AC-1, AC-2. + +### D6 — Владелец диск-алерта: disk_watchdog остаётся основным; sidecar — opt-in критический потолок (BR-10, FR-9) — **ключевое решение** + +BRD §BR-10 / FR-9 / AC-5 явно делегируют выбор владельца архитектору. **Решение:** + +1. **Штатный диск-алерт на 85% остаётся ЕДИНСТВЕННО за внутренним `disk_watchdog` (ORCH-063), через + Telegram орка.** Sidecar **НЕ** запускает независимый диск-алерт на том же пороге ⇒ **нулевой дубль + по построению** (AC-5 удовлетворён структурно, а не throttle-эвристикой). +2. **Вклад sidecar в дисковую безопасность — покрытие именно того провала, который F1b и создаётся + закрывать:** когда орк (а с ним и in-process `disk_watchdog`) **завис/упал**, штатный диск-алерт + физически невозможен. Тогда срабатывает **`orch_down`** — мастер-сигнал sidecar с независимого + канала; его текст явно подсказывает «in-process стражи (диск/reaper/reconciler) тоже мертвы → + проверьте хост, включая диск». +3. **Крайний edge — орк жив, но его Telegram сломан** (диск растёт, `disk_watchdog` не может + доставить): sidecar несёт **opt-in** независимый алерт `host_disk_crit` на **более высоком** + пороге-потолке (дефолт 97%, **выключен по умолчанию** `WATCHDOG_DISK_CRIT_ENABLED=false`). Это + **другое событие** (критический потолок, независимый канал), а не повтор 85%-события ⇒ инвариант + «не более одного алерта на одно событие переполнения» сохранён. Включается оператором осознанно, + когда нужна избыточность канала. + +Итог: из коробки — ровно один владелец диска (`disk_watchdog`); резервирование канала — обратимый +opt-in. Решение и обоснование зафиксированы здесь (AC-5). + +Привязка: BR-10, FR-9, AC-5. + +### D7 — Независимый Telegram-транспорт (BR-8, NFR-4, FR-8) + +`watchdog/notify.py` читает **свои** `WATCHDOG_TG_BOT_TOKEN` / `WATCHDOG_TG_CHAT_ID` из env и шлёт +через `urllib` POST на `api.telegram.org`. **Запрещено** импортировать `src/notifications.py` или +использовать токен/функции/чат орка — иначе падение/рефактор орка утянул бы алерт-канал (нарушение +C-1, прямой смысл BR-8). Отсутствие токена/chat → sidecar логирует и не шлёт (fail-safe), но **не +падает** (NFR-3). Сообщение несёт суть: сигнал, значение, порог, хост/контейнер. + +Привязка: BR-8, NFR-4, FR-8, AC-2, AC-6. + +### D8 — Three-level never-raise + kill-switch (NFR-3, NFR-5, FR-10, FR-11) + +- **per-source:** битый коллектор (орк down / docker.sock недоступен / пинг таймаут) деградирует + ОДИН сигнал, прочие собираются (`orch_down` сам по себе — нормальный сигнал, а не крах тика). +- **per-tick:** внешний `try/except` цикла — ошибка тика логируется, не валит демон. +- **per-send:** обёрнутый `notify` — сбой Telegram логируется и проглатывается (best-effort). +- **Kill-switch** `WATCHDOG_ENABLED` (env): `false` → демон **инертен** (idle-loop с логом «disabled», + НЕ `exit`, чтобы `restart: unless-stopped` не крутил рестарт-петлю) ⇒ нулевой эффект на орк и + конвейер. Полная обратимость: не запускать сервис вовсе / `WATCHDOG_ENABLED=false`. + +Привязка: NFR-1, NFR-3, NFR-5, FR-10, FR-11, AC-3, AC-4. + +### D9 — Толерантность к версии `/metrics` (NFR-6, FR-2) + +`collectors/orch.py` парсит конверт защитно: неизвестные ключи игнорируются, отсутствие +опционального — не ошибка (дефолт `None`/`[]`/`{}`), `enabled:false` трактуется явно (орк сам +выключил `/metrics` — не `orch_down`). Рост `schema_version` выше известного → `logger.warning` +(«новая версия контракта, читаю совместимое подмножество»), **не** крэш. Это зеркалит аддитивно- +толерантную политику F1a (adr-0030 D2): sidecar обязан пережить расширение `/metrics` без правок. + +Привязка: NFR-6, FR-2, AC-1. + +## Альтернативы + +- **Go-стек / `docker` SDK / `requests`** — отвергнуто: вес/вторая цепочка инструментов против C-3 и + C-1-консистентности с `disk_watchdog` (D1). +- **Prometheus/Grafana/TSDB/дашборд** — отвергнуто: прямой запрет C-3 (тонкий стек, хост впритык). +- **Sidecar — единственный владелец диска (внутренний `disk_watchdog` выключить)** — отвергнуто: + потеря покрытия диска, когда сам sidecar/хост-Docker недоступен; `disk_watchdog` дешёв и уже в + проде. Выбрана связка «disk_watchdog primary + sidecar opt-in ceiling» (D6). +- **Sidecar дублирует диск на 85% с дедупом по времени** — отвергнуто: хрупкая координация двух + каналов на одном событии; структурное «один владелец на порог» надёжнее (D6). +- **Push метрик из орка в sidecar** — отвергнуто: при зависшем орке push не уходит; pull-опрос + падает = **сам сигнал** `orch_down` (C-1). +- **bridge-сеть + `host.docker.internal`** — отвергнуто: на Linux ненадёжно; `network_mode: host` + проще и достигает и `/metrics`, и хост-интерфейсов (D2). +- **Своя БД/файл состояния порогов** — отвергнуто: тонкий стек (C-3); in-memory best-effort + достаточно (рестарт → корректный повторный алерт стоящей проблемы), как `disk_watchdog` (D4). + +## Последствия + +- **+** Появляется внешний мозг мониторинга, переживающий падение орка — закрыт корневой пробел + «in-process стражи лягут вместе с орком»; `orch_down` делает наблюдателя **громче** в инцидент. +- **+** Строго read-only к наблюдаемому (docker.sock `:ro` + GET-only, нет записи в БД/диск/`main`, + нет start/stop/restart/exec) + независимый канал ⇒ self-hosting-безопасно (enduro-trails не + затронут); падение sidecar не влияет на конвейер (NFR-1/4, AC-6). +- **+** Аддитивно и обратимо: новая папка `watchdog/`, новый сервис compose, новые `WATCHDOG_*` env. + `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД орка — байт-в-байт. Kill-switch → + нулевая регрессия. +- **+** Дубль диск-алерта исключён структурно (D6): один владелец на порог; резерв канала — opt-in. +- **−** Новый рантайм-контейнер на впритык-хосте: бюджет памяти `mem_limit: 128m` (D2) + измерение + фактического RSS на staging — обязательны (10-tech-risks TR-4). +- **−** C-2: падёт весь хост/Docker → молчит и sidecar (принятый заказчиком риск; внешнее плечо L2 + отложено). +- **−** Новая поверхность совместимости `/metrics`↔F1b — митигируется толерантным парсингом (D9) + + единым репо контракта (adr-0030). CPU-liveness Linux-специфичен (`/proc`); не-Linux → сигнал + `agent_hung` деградирует в none, не ошибка. +- **Топология:** меняется (новый контейнер) → см. `07-infra-requirements.md` (разовое действие: + добавить сервис в compose, создать bot/chat watchdog, смонтировать docker.sock `:ro` + хост-пути, + первый запуск). **Схема БД:** не меняется → `08-data-requirements.md` = N/A. +- **Эскалация:** новый компонент наблюдаемости + новый рантайм-контейнер + новый алерт-канал → лейбл + **`arch:major-change`** (консервативно, хоть изменение аддитивно/read-only/обратимо). Прод-выкат — + строго через staging-гейт (8501); деплой sidecar НЕ рестартит прод-контейнер `orchestrator`. +- **Откат:** не запускать сервис / `WATCHDOG_ENABLED=false` (мгновенный); удаление папки `watchdog/` + + сервиса из compose + `WATCHDOG_*` env — полный откат без следов (нет БД/схемы/изменений `src`). + +## Ссылки +- BRD: `docs/work-items/ORCH-100/01-brd.md` +- TRZ: `docs/work-items/ORCH-100/02-trz.md` +- Acceptance: `docs/work-items/ORCH-100/03-acceptance-criteria.md` +- Инфра-требования: `docs/work-items/ORCH-100/07-infra-requirements.md` +- Данные: `docs/work-items/ORCH-100/08-data-requirements.md` (N/A) +- Тех-риски: `docs/work-items/ORCH-100/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0033-sidecar-watchdog.md` +- Сверено по коду: `src/disk_watchdog.py` (`decide_action`/`PathAlertState`/трёхуровневый never-raise + — эталон D4/D8), `docker-compose.yml` (`network_mode: host`, `docker.sock` mount — база D2), + `src/metrics.py`/adr-0030 (контракт `/metrics`, `cpu_ticks`/`clk_tck`/`generated_at` — D5/D9). +- Связанные ADR: adr-0030 (F1a `/metrics` — источник сырья, парный контракт), adr-0024 + (`disk_watchdog` — образец решающей функции/never-raise/владелец диск-алерта), adr-0025 + (build-cache-pruner — «вторая половина» паттерн), adr-0017 (serial_gate — leaf never-raise), + adr-0011 (job-reaper — pid/liveness-семантика). + + diff --git a/docs/work-items/ORCH-100/07-infra-requirements.md b/docs/work-items/ORCH-100/07-infra-requirements.md new file mode 100644 index 0000000..e90f1dd --- /dev/null +++ b/docs/work-items/ORCH-100/07-infra-requirements.md @@ -0,0 +1,93 @@ +--- +work_item: ORCH-100 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-100 — FND/F1b: sidecar-watchdog + +Work Item: **ORCH-100** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable: топология **меняется** (новый рантайм-контейнер). Разовое инфра-действие выполняет +> человек (Слава/Стрим) на хосте mva154; дальше код `watchdog/` катится через конвейер (self-hosting). + +## I-1. Топология / окружения + +Новый сервис `orchestrator-watchdog` в `docker-compose.yml` — **отдельный контейнер** рядом с +`orchestrator` (8500) и `orchestrator-staging` (8501, profile staging). +- **Образ:** `build: ./watchdog` (`watchdog/Dockerfile`, `python:3.12-slim`, stdlib-only). +- **Сеть:** `network_mode: host` — достаёт `/metrics` орка как `http://127.0.0.1:8500/metrics` и + хост-интерфейсы (ADR-001 D2). +- **Тома (все read-only к наблюдаемому, NFR-4):** + - `/var/run/docker.sock:/var/run/docker.sock:ro` — статусы контейнеров (GET-only). + - `/home/slin/repos:/repos:ro` и `./data:/app/data:ro` (или эквивалент) — дисковые метрики хоста + через `shutil.disk_usage` (те же пути, что у `disk_watchdog`). +- **Лимиты:** `mem_limit: 128m` + `mem_reservation: 32m` (тонкость измерима/принудительна, NFR-2); + `restart: unless-stopped` (самовосстановление, FR-1). +- **Kill-switch:** `WATCHDOG_ENABLED` (env). `false` → демон инертен (idle-loop, не exit — чтобы + `restart` не крутил петлю), нулевой эффект на орк. +- **Контейнеры под наблюдением (BR-4):** минимум `orchestrator`; список `WATCHDOG_CONTAINERS` (CSV). +- **Образец сервиса (ориентир для developer; точные пути сверить с актуальным `docker-compose.yml`):** + ```yaml + orchestrator-watchdog: + build: ./watchdog + container_name: orchestrator-watchdog + restart: unless-stopped + network_mode: host + mem_limit: 128m + mem_reservation: 32m + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /home/slin/repos:/repos:ro + - ./data:/app/data:ro + env_file: .env.watchdog # ЛИБО общий .env с префиксом WATCHDOG_ (деталь — developer/оператор) + group_add: ["999"] # docker-группа для чтения docker.sock (как у орка) + ``` + +## I-2. Переменные окружения / секреты + +Канон (без секретов) — в `.env.example` (TRZ §2). Префикс `WATCHDOG_` (изоляция от `ORCH_`): +- **Секреты (только на хосте, в гит НЕ коммитятся):** `WATCHDOG_TG_BOT_TOKEN`, `WATCHDOG_TG_CHAT_ID` + — **собственные** bot/chat sidecar, независимые от Telegram орка (BR-8). Отсутствие → sidecar + логирует и не шлёт (fail-safe), но не падает. +- **Управление:** `WATCHDOG_ENABLED` (kill-switch), `WATCHDOG_INTERVAL_S` (дефолт 60), + `WATCHDOG_ORCH_METRICS_URL` (дефолт `http://127.0.0.1:8500/metrics`). +- **Пороги/таймауты (дефолты — ADR-001 D5):** `WATCHDOG_ORCH_DOWN_TICKS=3`, `WATCHDOG_MEM_PCT=90`, + `WATCHDOG_DISK_CRIT_ENABLED=false`, `WATCHDOG_DISK_CRIT_PCT=97`, `WATCHDOG_AGENT_HUNG_MIN=20`, + `WATCHDOG_AGENT_CPU_FLOOR=0.01`, `WATCHDOG_STAGE_STUCK_MIN=120`, `WATCHDOG_QUEUE_DEPTH=20`, + `WATCHDOG_COOLDOWN_S` (анти-спам realert), `WATCHDOG_HTTP_TIMEOUT_S`. +- **Цели:** `WATCHDOG_CONTAINERS` (CSV, дефолт `orchestrator`), `WATCHDOG_DEP_PLANE_URL`/ + `WATCHDOG_DEP_GITEA_URL`/`WATCHDOG_DEP_ANTHROPIC_URL` (health/ping). + +> Анти-дубль диск-алерта (ADR-001 D6): штатный 85%-алерт остаётся за внутренним `disk_watchdog` +> (ORCH-063). `WATCHDOG_DISK_CRIT_ENABLED` по умолчанию `false` — sidecar НЕ дублирует диск, пока +> оператор осознанно не включит независимый критический потолок. + +## I-3. Деплой / рестарт + +- **Разовое действие человеком на хосте (Слава/Стрим):** + 1. Создать **отдельного** Telegram-бота watchdog + получить chat-id; положить `WATCHDOG_TG_*` в + `.env.watchdog` (или `.env`) на хосте. + 2. Заполнить пороги/интервалы (дефолты годятся), включить `WATCHDOG_ENABLED=true`. + 3. Добавить сервис в `docker-compose.yml` (приходит с PR) и поднять **только его:** + `docker compose up -d --build orchestrator-watchdog`. +- **Self-hosting инвариант (критично):** поднятие/пересборка `orchestrator-watchdog` **НЕ** трогает + прод-контейнер `orchestrator` (отдельный сервис) — конвейер всех проектов не прерывается. **НЕ** + выполнять `docker compose up -d` без явного имени сервиса, если это спровоцирует рекреейт орка. +- **Прод-выкат кода watchdog** — через штатный self-hosting-конвейер и **обязательный staging-гейт + (8501)** перед прод-деплоем; деплой sidecar не рестартит прод-контейнер орка. +- **Проверка после старта (NFR-7):** `docker logs orchestrator-watchdog` показывает старт + тики; + тестовый алерт приходит в канал watchdog; остановка орка (на staging) → приходит `orch_down`. + +## I-4. CI/CD + +- Без изменений `.gitea/workflows/` по существу: новые тесты sidecar (`watchdog/tests/` или + `tests/watchdog/`) подхватываются существующим `pytest tests/`/прогоном (изолированы, чистые + функции — без контейнера/таймера). Если выбран отдельный путь `watchdog/tests/`, developer + обеспечивает его включение в существующий тест-ран (без нового workflow-файла). +- Docker-сборка нового образа — стандартным `docker compose build` (отдельный `watchdog/Dockerfile`), + без правок пайплайна CI. + diff --git a/docs/work-items/ORCH-100/08-data-requirements.md b/docs/work-items/ORCH-100/08-data-requirements.md new file mode 100644 index 0000000..65d6deb --- /dev/null +++ b/docs/work-items/ORCH-100/08-data-requirements.md @@ -0,0 +1,40 @@ +--- +work_item: ORCH-100 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-100 — FND/F1b: sidecar-watchdog + +Work Item: **ORCH-100** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable. Создан для аудитопригодности: фиксирует, что схема БД **не меняется** — это +> архитектурное утверждение (sidecar вне процесса орка, без своей БД), а не пропуск. + +## Изменения схемы БД орка + +**N/A.** Sidecar **не пишет** в БД орка (NFR-4: строго read-only к наблюдаемому — нет +`INSERT/UPDATE/DELETE/CREATE/ALTER`) и **не читает** её напрямую: всё орк-сырьё идёт через +`GET /metrics` (F1a, adr-0030). `tasks`/`jobs`/`agent_runs`/`STAGE_TRANSITIONS`/`QG_CHECKS` — +не тронуты. + +## Собственное хранилище sidecar + +**Нет (по решению C-3 / ADR-001 D4).** Состояние порогов (`AlertState`: `alerting`/`last_alert_at` +per signal_key) — **in-memory best-effort** в процессе демона: ни таблицы, ни файла, ни миграции. +Рестарт sidecar сбрасывает карту состояний → ещё стоящая проблема корректно повторно алертится один +раз (ранний сигнал, не SLA) — 1:1 семантика `disk_watchdog.PathAlertState` (ORCH-063). + +## Журнал уроков (F2) + +**Вне объёма.** Долговременное хранение инцидентов/уроков (потенциально БД орка) — отдельная задача +домена F2; F1b ничего не персистит (BRD §«Вне объёма»). + +## Вывод + +Изменений данных/схемы нет. Контракт данных F1b — **потребление** версионированного JSON `/metrics` +(adr-0030) + эфемерное in-memory состояние порогов. Откат не оставляет следов в БД. + diff --git a/docs/work-items/ORCH-100/10-tech-risks.md b/docs/work-items/ORCH-100/10-tech-risks.md new file mode 100644 index 0000000..1d44080 --- /dev/null +++ b/docs/work-items/ORCH-100/10-tech-risks.md @@ -0,0 +1,44 @@ +--- +work_item: ORCH-100 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-100 — FND/F1b: sidecar-watchdog + +Work Item: **ORCH-100** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Реестр рисков реализации F1b и митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Дубль диск-алерта** с `disk_watchdog` (ORCH-063) на одно событие переполнения. | Сред. | Низ. | ADR-001 D6: 85% остаётся ЕДИНСТВЕННО за `disk_watchdog` (канал орка); sidecar НЕ дублирует порог — `host_disk_crit` opt-in (default off) и на другом пороге-потолке (97%, другой канал = другое событие). Структурно один владелец на порог. | +| TR-2 | **Ложный `orch_down`** на одиночной сетевой икоте `/metrics` (флапп). | Сред. | Сред. | Порог `WATCHDOG_ORCH_DOWN_TICKS` (K подряд неудачных опросов, дефолт 3) + cooldown/recovery decide() (FR-3). Единичный transient → none. | +| TR-3 | **Sidecar толстеет** (память на впритык-хосте, 171Mi free) и сам становится проблемой. | Низ. | Сред. | Stdlib-only Python, один поток (D1); `mem_limit: 128m` + `mem_reservation: 32m` принудительно (D2); **обязательный замер фактического RSS на staging** перед прод-выкатом; OOM = ранний сигнал, не тихий рост. | +| TR-4 | **Привилегии docker.sock** — доступ к Docker API = потенциально мощно. | Низ. | Выс. | Mount `:ro` (NFR-4) + код делает ТОЛЬКО GET (list/inspect), без `docker` SDK — мутаций нет по построению; ревью + статпроверка (AC-6/TC-09). | +| TR-5 | **Дрейф контракта `/metrics`** (F1a расширили/сломали) роняет/искажает sidecar. | Низ. | Сред. | Толерантный парсинг (D9): неизвестные ключи игнор, отсутствие опционального не ошибка, рост `schema_version` → warning не крэш; единый репо контракта (adr-0030); ломающее изменение `/metrics` — отдельная задача-расширение F1a, не F1b. | +| TR-6 | **Шум алертов** (флапп на границе порога agent_hung/stage_stuck/mem). | Сред. | Низ. | Чистая decide() с cooldown/realert/recovery (D4, образец disk_watchdog); пороги/cooldown из env (тюнинг без релиза); `agent_hung` требует 2 опросов + CPU-floor (не дёргается на коротких паузах). | +| TR-7 | **Self-hosting: деплой sidecar задел прод-контейнер** `orchestrator`. | Низ. | Выс. | Отдельный сервис; `docker compose up -d orchestrator-watchdog` поднимает только его (07 I-3); прод-выкат через staging-гейт (8501); деплой sidecar не рестартит орк. | +| TR-8 | **`network_mode: host`** у sidecar — разделяет сетевой namespace хоста. | Низ. | Низ. | Sidecar read-only, не слушает входящих портов (опц. liveness вне обязательного объёма); host-network нужен для достижимости `/metrics` и хост-интерфейсов (D2); поверхность минимальна. | +| TR-9 | **Утечка/отсутствие** `WATCHDOG_TG_*` (свой бот) → алерты не доходят/секрет в гит. | Низ. | Сред. | Секреты только в `.env*` на хосте, канон без значений в `.env.example` (правило 8); отсутствие токена → fail-safe (лог, не падение, не шлёт); префикс `WATCHDOG_` изолирует от `ORCH_`. | +| TR-10 | **C-2: падёт весь хост/Docker** → молчит и sidecar (нет внешнего плеча). | Низ. | Выс. | Принятый заказчиком риск (одна площадка); внешнее плечо L2 сознательно отложено (BRD §«Вне объёма»). Документируется, не закрывается в F1b. | + +## Сводный вывод + +Доминирующий класс — **операционно-инфраструктурный** (привилегии docker.sock, память впритык, +self-hosting-безопасность), а не алгоритмический: ядро (decide/парсинг) — чистые тестируемые функции, +перенос зрелого паттерна `disk_watchdog`. Все мутирующие пути закрыты по построению (read-only mount + +GET-only, нет записи в БД/`main`), независимый алерт-канал и kill-switch дают полную обратимость. +Остаточный риск для прод-конвейера (enduro-trails и пр.) — **near-zero**: F1b физически вне процесса +орка и вне конвейера QG, при выключенном флаге — нулевой эффект. + +**Эскалация:** новый компонент наблюдаемости + новый рантайм-контейнер + новый алерт-канал → лейбл +**`arch:major-change`** (консервативно). Возврат в анализ **не требуется** — ТЗ выполнимо в рамках +принципов (всё в Docker на одном сервере, тонкий стек, минимум зависимостей). Обязательное +предусловие приёмки developer/tester: **замер фактического RSS sidecar на staging** (TR-3). + From 259b507906e2335bf1a56b499daeb77a9520f7b7 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 09:05:01 +0300 Subject: [PATCH 04/10] =?UTF-8?q?feat(watchdog):=20sidecar-watchdog=20F1b?= =?UTF-8?q?=20=E2=80=94=20monitoring=20brain=20in=20a=20separate=20contain?= =?UTF-8?q?er=20(ORCH-100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `watchdog/` package (thin Python-3.12 stdlib-only daemon) and the `orchestrator-watchdog` compose service — the brain half of the domain-0 observability pair. F1a (ORCH-099) exposes GET /metrics raw signal; F1b reads it, augments with host / container / dependency probes, runs each signal through a generalised pure decision function (decide(signal_active, prev, now, cooldown), a strict superset of disk_watchdog.decide_action) with per-signal in-memory dedup/throttle/recovery, and alerts over its OWN independent Telegram channel. Key properties (ADR-001): - Observer separated from observed: separate container; /metrics not answering is itself the master `orch_down` alarm (debounced K ticks — no flap on a hiccup). - Strictly read-only: docker.sock GET-only + mounted :ro (double guard), host paths :ro, no DB/disk writes, no process control — self-hosting-safe. - never-raise on three levels (per-source/per-tick/per-send) + WATCHDOG_ENABLED kill-switch (disabled -> inert idle-loop, not exit). - Disk anti-duplicate (D6): disk_watchdog (ORCH-063) stays sole owner of the 85% alert; sidecar carries orch_down + an opt-in 97% ceiling (default off). - NO import from src/** (C-1); src/**, STAGE_TRANSITIONS, QG_CHECKS, check_*, DB schema — untouched. env_file optional so a missing .env.watchdog never breaks `docker compose up` for the prod orchestrator. Tests: tests/watchdog/ (TC-01…TC-13) + full tests/ regression green (TC-14). Docs: CHANGELOG, .env.example canon (WATCHDOG_*); architecture README + adr-0033 authored at the architecture stage. Refs: ORCH-100 Co-Authored-By: Claude Opus 4.8 --- .env.example | 45 +++ .task-dev.md | 4 +- CHANGELOG.md | 9 + docker-compose.yml | 33 ++ tests/watchdog/__init__.py | 0 tests/watchdog/conftest.py | 46 +++ tests/watchdog/test_compose_service.py | 66 ++++ tests/watchdog/test_config_killswitch.py | 69 +++++ tests/watchdog/test_decision.py | 56 ++++ tests/watchdog/test_deps_collector.py | 39 +++ tests/watchdog/test_disk_alert_dedup.py | 42 +++ tests/watchdog/test_docker_readonly.py | 79 +++++ tests/watchdog/test_host_collector.py | 54 ++++ tests/watchdog/test_metrics_parse.py | 118 ++++++++ tests/watchdog/test_never_raise.py | 88 ++++++ tests/watchdog/test_notify_isolation.py | 84 ++++++ tests/watchdog/test_orch_down.py | 67 +++++ .../test_tick_orch_down_integration.py | 106 +++++++ watchdog/Dockerfile | 28 ++ watchdog/__init__.py | 31 ++ watchdog/__main__.py | 75 +++++ watchdog/collectors/__init__.py | 5 + watchdog/collectors/containers.py | 119 ++++++++ watchdog/collectors/deps.py | 51 ++++ watchdog/collectors/host.py | 75 +++++ watchdog/collectors/orch.py | 118 ++++++++ watchdog/config.py | 159 ++++++++++ watchdog/core.py | 183 +++++++++++ watchdog/decision.py | 63 ++++ watchdog/notify.py | 68 +++++ watchdog/signals.py | 283 ++++++++++++++++++ 31 files changed, 2261 insertions(+), 2 deletions(-) create mode 100644 tests/watchdog/__init__.py create mode 100644 tests/watchdog/conftest.py create mode 100644 tests/watchdog/test_compose_service.py create mode 100644 tests/watchdog/test_config_killswitch.py create mode 100644 tests/watchdog/test_decision.py create mode 100644 tests/watchdog/test_deps_collector.py create mode 100644 tests/watchdog/test_disk_alert_dedup.py create mode 100644 tests/watchdog/test_docker_readonly.py create mode 100644 tests/watchdog/test_host_collector.py create mode 100644 tests/watchdog/test_metrics_parse.py create mode 100644 tests/watchdog/test_never_raise.py create mode 100644 tests/watchdog/test_notify_isolation.py create mode 100644 tests/watchdog/test_orch_down.py create mode 100644 tests/watchdog/test_tick_orch_down_integration.py create mode 100644 watchdog/Dockerfile create mode 100644 watchdog/__init__.py create mode 100644 watchdog/__main__.py create mode 100644 watchdog/collectors/__init__.py create mode 100644 watchdog/collectors/containers.py create mode 100644 watchdog/collectors/deps.py create mode 100644 watchdog/collectors/host.py create mode 100644 watchdog/collectors/orch.py create mode 100644 watchdog/config.py create mode 100644 watchdog/core.py create mode 100644 watchdog/decision.py create mode 100644 watchdog/notify.py create mode 100644 watchdog/signals.py diff --git a/.env.example b/.env.example index ddf731b..3a2ecbd 100644 --- a/.env.example +++ b/.env.example @@ -465,3 +465,48 @@ ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500 # DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully # degrades to 200 (the process never crashes on startup). ORCH_QG0_TITLE_MAX=200 + +# ── ORCH-100 (FND/F1b): sidecar-watchdog (orchestrator-watchdog container) ───── +# The monitoring brain runs in a SEPARATE container with its OWN config. These +# keys are read by the watchdog package (watchdog/config.py), NOT by the +# orchestrator. At runtime they live in `.env.watchdog` (env_file of the +# orchestrator-watchdog service); this block is the canon. NO real secrets here. +# ENABLED -> kill-switch; false (or not starting the service) -> inert. +# INTERVAL_S -> seconds between ticks. +# HTTP_TIMEOUT_S -> per-request timeout (metrics / pings / docker / telegram). +# COOLDOWN_S -> re-alert throttle for a sustained signal (anti-spam). +# METRICS_URL -> orchestrator /metrics (host-network -> 127.0.0.1:8500). +# ORCH_DOWN_TICKS-> K consecutive /metrics failures before "орк не отвечает". +# MEM_PCT -> host memory used-% threshold. +# DISK_CRIT_* -> OPT-IN independent disk CEILING (disk_watchdog/ORCH-063 owns +# the 85% alert; this is a higher ceiling on the sidecar's own +# channel, OFF by default -> no double disk-alert, AC-5/D6). +# DISK_PATHS -> host paths measured for the opt-in ceiling. +# AGENT_HUNG_MIN -> runtime minutes before an agent with ~0 CPU is "hung". +# AGENT_CPU_FLOOR-> CPU fraction below which a long-running agent counts as hung. +# STAGE_STUCK_MIN-> minutes a task may sit in one stage before alerting. +# QUEUE_DEPTH -> queued-job depth threshold. +# CONTAINERS -> CSV of container names to watch (status != running/healthy). +# DOCKER_SOCK -> path to the read-only docker.sock inside the container. +# DEPS -> CSV of name=url dependency pings (empty -> no pings). +# TG_BOT_TOKEN / TG_CHAT_ID -> the sidecar's OWN Telegram bot/chat (independent +# of the orchestrator's; absent -> logs, does not send). +WATCHDOG_ENABLED=true +WATCHDOG_INTERVAL_S=30 +WATCHDOG_HTTP_TIMEOUT_S=5 +WATCHDOG_COOLDOWN_S=1800 +WATCHDOG_METRICS_URL=http://127.0.0.1:8500/metrics +WATCHDOG_ORCH_DOWN_TICKS=3 +WATCHDOG_MEM_PCT=90 +WATCHDOG_DISK_CRIT_ENABLED=false +WATCHDOG_DISK_CRIT_PCT=97 +WATCHDOG_DISK_PATHS=/repos,/app/data +WATCHDOG_AGENT_HUNG_MIN=20 +WATCHDOG_AGENT_CPU_FLOOR=0.01 +WATCHDOG_STAGE_STUCK_MIN=120 +WATCHDOG_QUEUE_DEPTH=20 +WATCHDOG_CONTAINERS=orchestrator +WATCHDOG_DOCKER_SOCK=/var/run/docker.sock +WATCHDOG_DEPS= +WATCHDOG_TG_BOT_TOKEN= +WATCHDOG_TG_CHAT_ID= diff --git a/.task-dev.md b/.task-dev.md index b2687c2..8871bc5 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-057 +Work item: ORCH-100 Repo: orchestrator -Branch: feature/ORCH-057-bug-follow-up-orch-040-normali +Branch: feature/ORCH-100-fnd-f1b-sidecar-watchdog Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb3005..ea854ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **FND/F1b: sidecar-watchdog — мозг мониторинга в отдельном контейнере** (ORCH-100, `feat`): новая папка `watchdog/` (тонкий **Python-3.12-stdlib-only** демон) + сервис `orchestrator-watchdog` в `docker-compose.yml` (`network_mode: host`, read-only `docker.sock`, `mem_limit: 128m`). Вторая половина пары наблюдаемости домена 0: F1a (ORCH-099) отдаёт `GET /metrics` (сырьё), F1b — **мозг**, который это сырьё читает, дополняет внешними сигналами (хост/контейнеры/зависимости) и превращает в **алерты** через **собственный** независимый Telegram-канал. **`src/**` НЕ изменён** — F1b потребитель `/metrics`; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД орка — байт-в-байт. Аддитивно, под kill-switch `WATCHDOG_ENABLED`, строго read-only к наблюдаемому (self-hosting-безопасно). ADR: `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`, сквозной `docs/architecture/adr/adr-0033-sidecar-watchdog.md`. + - **Стек (D1):** Python 3.12 stdlib-only на `python:3.12-slim` — `urllib` (HTTP `/metrics` + пинги + Telegram POST), сырой HTTP-over-unix-socket для read-only `docker.sock` (БЕЗ pip-пакета `docker`), `shutil.disk_usage`/`/proc/meminfo` для хоста. Нет дерева зависимостей (тонкость, C-3). Отдельный образ `watchdog/Dockerfile` (build-контекст = корень репо; `src/**` НЕ копируется — изоляция C-1). + - **Топология (D2):** сервис собирается из `watchdog/Dockerfile`, `restart: unless-stopped` (самовосстановление), `network_mode: host` → `/metrics` достижим как `http://127.0.0.1:8500/metrics`; `docker.sock` смонтирован `:ro` И код GET-only (двойная гарантия read-only); хост-пути bind-mount `:ro`; `mem_limit: 128m`+`mem_reservation: 32m`. `env_file` опционален (`required: false`) → отсутствие `.env.watchdog` НЕ ломает `docker compose up` прод-орка. Деплой watchdog поднимает ТОЛЬКО его — прод `orchestrator` не пересобирается/не рестартится. + - **Обобщённая чистая решающая функция (D4):** `watchdog/decision.py::decide(signal_active, prev, now, cooldown_s) -> alert|realert|recovery|none` — строгая генерализация `disk_watchdog.decide_action` (булев `signal_active` вместо `used_pct >= threshold`), per-signal in-memory `AlertState` (анти-спам/recovery, рестарт сбрасывает → корректный повторный алерт стоящей проблемы). + - **Реестр сигналов (D5):** `orch_down` (K=3 подряд неудачных `/metrics` — debounce, не флаппит на одиночной икоте), `host_mem` (≥90%), `host_disk_crit` (opt-in потолок 97%, default off — D6), `agent_hung` (per run_id, два опроса: `runtime > N` И доля CPU `< floor`), `stage_stuck` (per work_item), `job_failed` (edge, рост счётчика), `queue_depth` (≥20), `container_down` (per name, статус ∉ {running,healthy}), `dep_down` (per name, пинг Plane/Gitea/Anthropic). Все пороги/интервалы/URL/токены — из env (`WATCHDOG_*`, канон в `.env.example`). + - **Анти-дубль диск-алерта (D6, AC-5):** штатные 85% остаются ЕДИНСТВЕННО за `disk_watchdog` (ORCH-063) → **нулевой дубль по построению**; вклад sidecar — `orch_down` (когда орк лёг, in-process стражи мертвы) + **opt-in** независимый потолок `host_disk_crit` (97%, default off) как резерв канала. Один владелец на порог. + - **Независимый транспорт (D7):** `watchdog/notify.py` читает **свои** `WATCHDOG_TG_BOT_TOKEN`/`WATCHDOG_TG_CHAT_ID`, **запрещён** импорт `src/notifications.py`/токена орка (падение орка не утянет алерт-канал). Отсутствие токена → fail-safe (логирует, не шлёт, не падает). + - **never-raise + kill-switch (D8):** три уровня (per-source: битый коллектор деградирует один сигнал; per-tick: внешний try/except цикла; per-send: обёрнутая отправка). `WATCHDOG_ENABLED=false` → демон инертен (idle-loop с логом, НЕ exit — чтобы restart-policy не крутил петлю). Толерантность к версии `/metrics` (D9): неизвестные поля игнорируются, рост `schema_version` логируется (warning) без крэша. + - Тесты: `tests/watchdog/test_*.py` (TC-01…TC-13: решение/orch-down/never-raise/kill-switch/full-tick/docker-readonly/notify-isolation/metrics-parse/compose/disk-dedup + коллекторы host/deps) + полный регресс `tests/ -q` зелёный (TC-14, `src/**` не тронут). **Инфра-предусловие** (07): добавить сервис в compose, создать bot/chat watchdog + `.env.watchdog`, первый запуск на хосте. Откат: не запускать сервис / `WATCHDOG_ENABLED=false`. - **Багфикс-трек: упрощённый/дешёвый маршрут конвейера для багов** (ORCH-019, `feat`): задача с меткой Plane `Bug` идёт **укороченным маршрутом** — пропускается стадия `architecture` (отдельный прогон opus-агента `architect` + ADR + exit-гейт `check_architecture_done`), тяжёлая аналитика заменяется облегчённым пакетом (короткий bug-report + обязательный план регресс-теста). **Все Quality Gate'ы исполняются без изменений** (корневой инвариант NFR-1): `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / сигнатуры `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/`coverage_status:`) — байт-в-байт прежние; маршрутизация багфикса — свойство планировщика, **не** гейт. Аддитивно, под kill-switch, с областью репо, never-raise, fail-safe → полный цикл. ADR: `docs/work-items/ORCH-019/06-adr/ADR-001-bug-fast-track.md`, сквозной `docs/architecture/adr/adr-0032-bug-fast-track.md`. - **Классификация (D1, FR-1):** новый leaf `src/bug_fast_track.py` (never-raise, паттерн `labels`/`serial_gate`). `bug_fast_track_applies(repo)` (локально, без сети) проверяется ПЕРВЫМ → выключенный флаг = нулевой сетевой оверхед; `is_bug_task(work_item_id, project_id)` делегирует в проверенный `labels.has_label` (ORCH-089: `fetch_issue_labels`+`get_project_labels`, нормализация, TTL-кэш). **Источник истины — Plane API**, не payload вебхука. Чтение метки — только в `start_pipeline`, **никогда** в горячем `claim_next_job` (NFR-4). - **Хранение типа (D2):** аддитивная идемпотентная колонка `tasks.track TEXT DEFAULT 'full'` (`_ensure_column`, паттерн `tasks.cancelled_at` ORCH-090); значения `'full'` (дефолт, ВСЕ существующие и не-баг задачи) | `'bug'`. Хелперы `db.set_task_track`/`db.get_task_track` (отсутствие/NULL → `'full'`, fail-safe). Сигнатура `create_task_atomic` не меняется. diff --git a/docker-compose.yml b/docker-compose.yml index 1c76518..30d68ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,39 @@ services: group_add: - "999" + # ORCH-100 (FND/F1b): sidecar-watchdog — the monitoring brain in a SEPARATE + # container (observer separated from observed, ADR-001 D2). Deploying it builds + # ONLY this service — the prod `orchestrator` is NOT rebuilt/restarted. + # * network_mode: host -> /metrics reachable at http://127.0.0.1:8500/metrics + # and host interfaces visible for memory/disk reads. + # * docker.sock mounted :ro AND the code is GET-only (double read-only guard). + # * host disk paths bind-mounted :ro so shutil.disk_usage sees the host FS but + # can never write (opt-in disk ceiling, D6). + # * mem_limit caps the thin stdlib daemon (D2): OOM = early "sidecar grew" signal. + # * WATCHDOG_ENABLED=false (or simply not starting the service) -> inert. + orchestrator-watchdog: + build: + context: . + dockerfile: watchdog/Dockerfile + container_name: orchestrator-watchdog + restart: unless-stopped + init: true + network_mode: host + mem_limit: 128m + mem_reservation: 32m + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /home/slin/repos:/repos:ro + - ./data:/app/data:ro + # Optional env_file (required: false): a missing .env.watchdog must NOT fail + # `docker compose up` for the prod orchestrator (self-hosting safety). Absent + # file -> WATCHDOG_* defaults, no token -> fail-safe (logs, does not send). + env_file: + - path: .env.watchdog + required: false + group_add: + - "999" + # ORCH-31: staging instance (port 8501, isolated DB). # Starts ONLY with: docker compose --profile staging up -d orchestrator-staging # Normal "docker compose up -d" does NOT start this service. diff --git a/tests/watchdog/__init__.py b/tests/watchdog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/watchdog/conftest.py b/tests/watchdog/conftest.py new file mode 100644 index 0000000..4656515 --- /dev/null +++ b/tests/watchdog/conftest.py @@ -0,0 +1,46 @@ +"""Shared helpers/fixtures for the watchdog (ORCH-100, F1b) test suite. + +A tiny urllib-style fake opener so HTTP collectors / Telegram transport never +touch the network (test plan §scope: all collectors/transport are mocked). +""" +from __future__ import annotations + +import io +import urllib.error + + +class FakeResponse: + """Context-manager response mimicking ``urllib`` ``addinfourl``.""" + + def __init__(self, status: int = 200, body: bytes = b"{}"): + self.status = status + self._body = body + + def getcode(self): + return self.status + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def make_opener(*, status=200, body=b"{}", exc=None): + """Build a fake ``urlopen`` that returns a body or raises ``exc``.""" + + def _opener(req, timeout=None): + if exc is not None: + raise exc + return FakeResponse(status=status, body=body) + + return _opener + + +def http_error(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="http://x", code=code, msg="err", hdrs=None, fp=io.BytesIO(b"") + ) diff --git a/tests/watchdog/test_compose_service.py b/tests/watchdog/test_compose_service.py new file mode 100644 index 0000000..359a950 --- /dev/null +++ b/tests/watchdog/test_compose_service.py @@ -0,0 +1,66 @@ +"""TC-12: compose invariant — orchestrator-watchdog is a separate service. + +It declares its own build (watchdog/Dockerfile), restart policy, mem_limit, and +mounts docker.sock read-only (:ro). Parses the real docker-compose.yml. +""" +import pathlib + +import yaml + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _compose(): + with open(REPO_ROOT / "docker-compose.yml") as f: + return yaml.safe_load(f) + + +def test_watchdog_service_declared(): + svc = _compose()["services"] + assert "orchestrator-watchdog" in svc + + +def test_watchdog_builds_from_watchdog_dockerfile(): + wd = _compose()["services"]["orchestrator-watchdog"] + build = wd["build"] + assert isinstance(build, dict) + assert build["dockerfile"] == "watchdog/Dockerfile" + assert build["context"] == "." + + +def test_watchdog_has_restart_and_mem_limit(): + wd = _compose()["services"]["orchestrator-watchdog"] + assert wd["restart"] == "unless-stopped" + assert wd["mem_limit"] == "128m" # thin stack, not Grafana/Prometheus + + +def test_docker_sock_mounted_read_only(): + wd = _compose()["services"]["orchestrator-watchdog"] + sock = [v for v in wd["volumes"] if "docker.sock" in v] + assert sock, "docker.sock must be mounted" + assert all(v.endswith(":ro") for v in sock), "docker.sock must be :ro" + + +def test_host_paths_mounted_read_only(): + wd = _compose()["services"]["orchestrator-watchdog"] + # Every bind mount the watchdog uses is read-only (it only reads). + for v in wd["volumes"]: + assert v.endswith(":ro"), f"watchdog mount must be :ro: {v}" + + +def test_env_file_is_optional(): + # A missing .env.watchdog must not break `docker compose up` (self-hosting). + wd = _compose()["services"]["orchestrator-watchdog"] + env_file = wd["env_file"] + assert isinstance(env_file, list) + assert env_file[0]["required"] is False + + +def test_watchdog_dockerfile_exists_and_is_stdlib_only(): + df = REPO_ROOT / "watchdog" / "Dockerfile" + assert df.exists() + text = df.read_text() + # No pip install of third-party deps (stdlib-only, D1). + assert "pip install" not in text + assert "COPY requirements" not in text + assert "requirements.txt" not in text diff --git a/tests/watchdog/test_config_killswitch.py b/tests/watchdog/test_config_killswitch.py new file mode 100644 index 0000000..001d14a --- /dev/null +++ b/tests/watchdog/test_config_killswitch.py @@ -0,0 +1,69 @@ +"""TC-07: kill-switch + env-driven config (no hardcoded thresholds). + +``WATCHDOG_ENABLED=false`` -> the daemon is inert (idle, no ticks). Thresholds / +intervals / timeouts come from env, not constants. +""" +from watchdog.config import Config + + +def test_killswitch_off_is_inert(monkeypatch): + from watchdog import __main__ as entry + + cfg = Config.from_env({"WATCHDOG_ENABLED": "false", "WATCHDOG_INTERVAL_S": "0"}) + assert cfg.enabled is False + + built = {"n": 0} + + class _Dog: + def tick(self): + built["n"] += 1 + + # If run() ever constructed a Watchdog / ticked while disabled, this would fire. + monkeypatch.setattr(entry, "Watchdog", lambda c: _Dog()) + monkeypatch.setattr(entry.time, "sleep", lambda *_: None) + entry.run(cfg=cfg, max_ticks=3) + assert built["n"] == 0 # inert: never ticked + + +def test_thresholds_read_from_env(): + cfg = Config.from_env( + { + "WATCHDOG_INTERVAL_S": "7", + "WATCHDOG_MEM_PCT": "77", + "WATCHDOG_QUEUE_DEPTH": "9", + "WATCHDOG_AGENT_HUNG_MIN": "5", + "WATCHDOG_STAGE_STUCK_MIN": "11", + "WATCHDOG_ORCH_DOWN_TICKS": "4", + "WATCHDOG_COOLDOWN_S": "60", + "WATCHDOG_HTTP_TIMEOUT_S": "2", + "WATCHDOG_CONTAINERS": "orchestrator,plane-app", + "WATCHDOG_DEPS": "gitea=http://g/healthz,plane=http://p/", + } + ) + assert cfg.interval_s == 7.0 + assert cfg.mem_pct == 77.0 + assert cfg.queue_depth == 9 + assert cfg.agent_hung_s == 5 * 60.0 + assert cfg.stage_stuck_s == 11 * 60.0 + assert cfg.orch_down_ticks == 4 + assert cfg.cooldown_s == 60.0 + assert cfg.http_timeout_s == 2.0 + assert cfg.containers == ["orchestrator", "plane-app"] + assert cfg.deps == {"gitea": "http://g/healthz", "plane": "http://p/"} + + +def test_defaults_when_env_absent(): + cfg = Config.from_env({}) + assert cfg.enabled is True + assert cfg.interval_s == 30.0 + assert cfg.metrics_url.endswith(":8500/metrics") + assert cfg.disk_crit_enabled is False + assert cfg.containers == ["orchestrator"] + assert cfg.deps == {} + + +def test_malformed_env_degrades_to_default(): + # A garbage numeric value must not crash config; it degrades to the default. + cfg = Config.from_env({"WATCHDOG_INTERVAL_S": "abc", "WATCHDOG_MEM_PCT": ""}) + assert cfg.interval_s == 30.0 + assert cfg.mem_pct == 90.0 diff --git a/tests/watchdog/test_decision.py b/tests/watchdog/test_decision.py new file mode 100644 index 0000000..92e7aea --- /dev/null +++ b/tests/watchdog/test_decision.py @@ -0,0 +1,56 @@ +"""TC-01…TC-04: the pure decision function (alert/throttle/realert/recovery). + +Mirrors the disk_watchdog.decide_action tests — the generalised ``decide`` is a +strict superset (boolean ``signal_active`` instead of ``used_pct >= threshold``). +""" +from watchdog.decision import ( + ACTION_ALERT, + ACTION_NONE, + ACTION_REALERT, + ACTION_RECOVERY, + AlertState, + decide, +) + +COOLDOWN = 1800.0 + + +def test_tc01_not_alerting_active_alerts(): + # TC-01: not-alerting & signal active -> ALERT (one per crossing). + prev = AlertState(alerting=False) + assert decide(True, prev, now=100.0, cooldown_s=COOLDOWN) == ACTION_ALERT + + +def test_tc01_not_alerting_inactive_is_none(): + prev = AlertState(alerting=False) + assert decide(False, prev, now=100.0, cooldown_s=COOLDOWN) == ACTION_NONE + + +def test_tc02_alerting_active_in_cooldown_is_none(): + # TC-02: alerting & still active & cooldown NOT elapsed -> NONE (anti-spam). + prev = AlertState(alerting=True, last_alert_at=1000.0) + assert decide(True, prev, now=1000.0 + 10.0, cooldown_s=COOLDOWN) == ACTION_NONE + + +def test_tc03_alerting_active_cooldown_elapsed_realerts(): + # TC-03: alerting & still active & cooldown elapsed -> REALERT. + prev = AlertState(alerting=True, last_alert_at=1000.0) + assert decide(True, prev, now=1000.0 + COOLDOWN, cooldown_s=COOLDOWN) == ACTION_REALERT + + +def test_tc03_alerting_active_no_last_alert_realerts(): + # Defensive: alerting but last_alert_at missing -> treat cooldown as elapsed. + prev = AlertState(alerting=True, last_alert_at=None) + assert decide(True, prev, now=5.0, cooldown_s=COOLDOWN) == ACTION_REALERT + + +def test_tc04_alerting_recovers_when_inactive(): + # TC-04: alerting & signal back to normal -> RECOVERY. + prev = AlertState(alerting=True, last_alert_at=1000.0) + assert decide(False, prev, now=1200.0, cooldown_s=COOLDOWN) == ACTION_RECOVERY + + +def test_cooldown_boundary_is_inclusive(): + # Exactly at cooldown boundary -> REALERT (>= semantics, like disk_watchdog). + prev = AlertState(alerting=True, last_alert_at=0.0) + assert decide(True, prev, now=COOLDOWN, cooldown_s=COOLDOWN) == ACTION_REALERT diff --git a/tests/watchdog/test_deps_collector.py b/tests/watchdog/test_deps_collector.py new file mode 100644 index 0000000..b6cb79d --- /dev/null +++ b/tests/watchdog/test_deps_collector.py @@ -0,0 +1,39 @@ +"""Dependency ping collector: reachable / unreachable / 5xx (never-raise).""" +from watchdog.collectors import deps as deps_mod + +from .conftest import http_error, make_opener + + +def test_ping_reachable(): + assert deps_mod.ping("http://x", 1.0, opener=make_opener(status=200)) is True + + +def test_ping_4xx_still_reachable(): + # A 4xx proves the host is up (we ping for liveness, not auth). + assert deps_mod.ping("http://x", 1.0, opener=make_opener(exc=http_error(404))) is True + + +def test_ping_5xx_is_down(): + assert deps_mod.ping("http://x", 1.0, opener=make_opener(exc=http_error(503))) is False + + +def test_ping_timeout_is_down(): + assert deps_mod.ping( + "http://x", 1.0, opener=make_opener(exc=TimeoutError()) + ) is False + + +def test_ping_all_mixed(): + def opener_factory(url): + return make_opener(status=200) if "good" in url else make_opener( + exc=ConnectionError() + ) + + def opener(req, timeout=None): + url = req.full_url if hasattr(req, "full_url") else req + return opener_factory(url)(req, timeout) + + res = deps_mod.ping_all( + {"good": "http://good", "bad": "http://bad"}, 1.0, opener=opener + ) + assert res == {"good": True, "bad": False} diff --git a/tests/watchdog/test_disk_alert_dedup.py b/tests/watchdog/test_disk_alert_dedup.py new file mode 100644 index 0000000..8930dfd --- /dev/null +++ b/tests/watchdog/test_disk_alert_dedup.py @@ -0,0 +1,42 @@ +"""TC-13: anti-duplicate disk alert (coordinated with ORCH-063 / disk_watchdog). + +ADR-001 D6: disk_watchdog (ORCH-063) is the SOLE owner of the 85% disk alert via +the orchestrator's Telegram. The sidecar carries NO disk alert by default +(``WATCHDOG_DISK_CRIT_ENABLED=false``) -> structurally zero double-alert. The +sidecar's contribution is an OPT-IN independent ceiling at a HIGHER threshold +(a different event, separate channel). +""" +from watchdog.config import Config +from watchdog.signals import host_signals + + +def _cfg(**kw): + return Config.from_env(kw) + + +def test_disk_signal_absent_by_default(): + # Disk full at 90% -> sidecar produces NO disk signal (disk_watchdog owns it). + cfg = _cfg() + assert cfg.disk_crit_enabled is False + sigs = host_signals(cfg, mem_pct=None, disk=("/repos", 90.0)) + assert [s for s in sigs if s.key == "host_disk_crit"] == [] + + +def test_opt_in_ceiling_is_separate_higher_event(): + cfg = _cfg(WATCHDOG_DISK_CRIT_ENABLED="true", WATCHDOG_DISK_CRIT_PCT="97") + # Below the ceiling (90% < 97%) -> not active even when opted in (no 85% dup). + below = host_signals(cfg, mem_pct=None, disk=("/repos", 90.0)) + crit_below = [s for s in below if s.key == "host_disk_crit"] + assert len(crit_below) == 1 and crit_below[0].active is False + + # At/over the high ceiling -> active (a DIFFERENT event from disk_watchdog 85%). + over = host_signals(cfg, mem_pct=None, disk=("/repos", 98.0)) + crit_over = [s for s in over if s.key == "host_disk_crit"] + assert len(crit_over) == 1 and crit_over[0].active is True + + +def test_mem_signal_independent_of_disk(): + cfg = _cfg(WATCHDOG_MEM_PCT="90") + sigs = host_signals(cfg, mem_pct=95.0, disk=None) + mem = [s for s in sigs if s.key == "host_mem"] + assert len(mem) == 1 and mem[0].active is True diff --git a/tests/watchdog/test_docker_readonly.py b/tests/watchdog/test_docker_readonly.py new file mode 100644 index 0000000..64bcf7d --- /dev/null +++ b/tests/watchdog/test_docker_readonly.py @@ -0,0 +1,79 @@ +"""TC-09: self-hosting safety — the Docker client is read-only by construction. + +The client exposes ONLY read methods (list/inspect), its single request +primitive hard-codes the ``GET`` HTTP method, and the source carries no +mutating Docker verb (start/stop/restart/kill/exec/POST). ``classify_container`` +is a pure status mapper. +""" +import inspect as _inspect + +from watchdog.collectors import containers as cmod + + +def test_request_primitive_is_get_only(monkeypatch): + captured = {} + + class _FakeConn: + def __init__(self, *a, **k): + pass + + def request(self, method, path): + captured["method"] = method + captured["path"] = path + + def getresponse(self): + class _R: + status = 200 + + def read(self_inner): + return b"[]" + + return _R() + + def close(self): + pass + + monkeypatch.setattr(cmod, "_UnixHTTPConnection", _FakeConn) + reader = cmod.DockerSockReader("/var/run/docker.sock") + reader.list_containers() + assert captured["method"] == "GET" + reader.inspect("orchestrator") + assert captured["method"] == "GET" + + +def test_no_mutating_verbs_in_source(): + src = _inspect.getsource(cmod) + lowered = src.lower() + # No write/control verbs should appear as Docker actions in this module. + for verb in ("/start", "/stop", "/restart", "/kill", "/exec", "\"post\"", "'post'"): + assert verb not in lowered, f"mutating verb leaked into containers.py: {verb}" + + +def test_reader_exposes_only_read_methods(): + public = [ + n for n in dir(cmod.DockerSockReader) + if not n.startswith("_") + ] + assert set(public) == {"list_containers", "inspect"} + + +def test_classify_container_pure_mapping(): + assert cmod.classify_container({"State": {"Status": "running"}}) == "running" + assert cmod.classify_container({"State": {"Status": "exited"}}) == "exited" + assert cmod.classify_container( + {"State": {"Status": "running", "Health": {"Status": "unhealthy"}}} + ) == "unhealthy" + assert cmod.classify_container( + {"State": {"Status": "running", "Health": {"Status": "healthy"}}} + ) == "healthy" + assert cmod.classify_container(None) == "unknown" + assert cmod.classify_container({}) == "unknown" + + +def test_container_alarm_semantics(): + assert cmod.container_alarm("running") is False + assert cmod.container_alarm("healthy") is False + assert cmod.container_alarm("exited") is True + assert cmod.container_alarm("restarting") is True + assert cmod.container_alarm("unhealthy") is True + assert cmod.container_alarm("unknown") is True diff --git a/tests/watchdog/test_host_collector.py b/tests/watchdog/test_host_collector.py new file mode 100644 index 0000000..3aa2986 --- /dev/null +++ b/tests/watchdog/test_host_collector.py @@ -0,0 +1,54 @@ +"""Host collector: /proc/meminfo parsing + disk reads (never-raise).""" +import os +import tempfile + +from watchdog.collectors import host as host_mod + + +def test_mem_used_pct_from_meminfo(): + content = "MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n" + with tempfile.NamedTemporaryFile("w", suffix=".meminfo", delete=False) as f: + f.write(content) + path = f.name + try: + pct = host_mod.read_mem_used_pct(path) + # used = (1 - 250/1000) * 100 = 75.0 + assert pct == 75.0 + finally: + os.unlink(path) + + +def test_mem_used_pct_missing_file_is_none(): + assert host_mod.read_mem_used_pct("/no/such/meminfo") is None + + +def test_mem_used_pct_garbage_is_none(): + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write("totally not meminfo\n") + path = f.name + try: + assert host_mod.read_mem_used_pct(path) is None + finally: + os.unlink(path) + + +def test_disk_used_pct_real_path(): + pct = host_mod.read_disk_used_pct("/") + assert pct is None or (0.0 <= pct <= 100.0) + + +def test_disk_used_pct_missing_path_is_none(): + assert host_mod.read_disk_used_pct("/no/such/path/xyz") is None + + +def test_max_disk_used_pct_picks_worst(monkeypatch): + monkeypatch.setattr( + host_mod, "read_disk_used_pct", + lambda p: {"/a": 10.0, "/b": 80.0, "/c": None}.get(p), + ) + assert host_mod.max_disk_used_pct(["/a", "/b", "/c"]) == ("/b", 80.0) + + +def test_max_disk_used_pct_all_unreadable(monkeypatch): + monkeypatch.setattr(host_mod, "read_disk_used_pct", lambda p: None) + assert host_mod.max_disk_used_pct(["/a", "/b"]) is None diff --git a/tests/watchdog/test_metrics_parse.py b/tests/watchdog/test_metrics_parse.py new file mode 100644 index 0000000..568da4e --- /dev/null +++ b/tests/watchdog/test_metrics_parse.py @@ -0,0 +1,118 @@ +"""TC-11: tolerance to the /metrics contract. + +Unknown fields are ignored, a missing optional does not crash, and a +schema_version above the known one logs a warning (no crash). Also covers the +envelope-derived signal evaluation (agent_hung / stage_stuck / job_failed / +queue_depth). +""" +import logging + +from watchdog.collectors import orch as orch_mod +from watchdog.config import Config +from watchdog.signals import AgentSample, eval_envelope + + +def _cfg(**kw): + return Config.from_env(kw) + + +def test_unknown_field_ignored(): + body = '{"schema_version":1,"stages":[],"brand_new_field":42}' + env = orch_mod.parse_envelope(body) + assert env["brand_new_field"] == 42 # tolerated, not a crash + + +def test_missing_optional_not_an_error(): + env = orch_mod.parse_envelope('{"schema_version":1}') + ev = eval_envelope(env, _cfg(), prev_agents={}, prev_failed=None) + assert ev.signals == [] # no stages/agents/queue -> no signals, no crash + + +def test_non_object_body_raises_valueerror(): + import pytest + + with pytest.raises(ValueError): + orch_mod.parse_envelope("[1,2,3]") + + +def test_schema_version_bump_warns(caplog): + env = {"schema_version": 999} + with caplog.at_level(logging.WARNING): + orch_mod.check_schema_version(env) + assert any("schema_version" in r.message for r in caplog.records) + + +def test_parse_generated_at_roundtrip_and_tolerant(): + assert orch_mod.parse_generated_at({"generated_at": "2026-06-10T00:00:00Z"}) + assert orch_mod.parse_generated_at({"generated_at": "garbage"}) is None + assert orch_mod.parse_generated_at({}) is None + + +def test_queue_depth_and_job_failed_signals(): + env = { + "schema_version": 1, + "queue": {"depth": 25, "counts": {"failed": 5}}, + } + cfg = _cfg(WATCHDOG_QUEUE_DEPTH="20") + # First tick: failed baseline established, depth over threshold fires. + ev = eval_envelope(env, cfg, prev_agents={}, prev_failed=None) + keys = {s.key for s in ev.signals} + assert "queue_depth" in keys + assert "job_failed" not in keys # no prior baseline -> no edge yet + assert ev.failed_count == 5 + + # Next tick: failed grew 5 -> 7 -> edge job_failed alert. + env2 = {"queue": {"depth": 0, "counts": {"failed": 7}}} + ev2 = eval_envelope(env2, cfg, prev_agents={}, prev_failed=ev.failed_count) + jf = [s for s in ev2.signals if s.key == "job_failed"] + assert len(jf) == 1 and jf[0].edge is True and jf[0].active is True + + +def test_stage_stuck_signal(): + env = {"stages": [{"work_item": "ORCH-1", "stage": "review", "age_in_stage_s": 9999}]} + cfg = _cfg(WATCHDOG_STAGE_STUCK_MIN="1") # 60s threshold + ev = eval_envelope(env, cfg, prev_agents={}, prev_failed=None) + stuck = [s for s in ev.signals if s.key == ("stage_stuck", "ORCH-1")] + assert len(stuck) == 1 and stuck[0].active is True + + +def test_agent_hung_needs_two_polls_and_low_cpu(): + cfg = _cfg(WATCHDOG_AGENT_HUNG_MIN="1", WATCHDOG_AGENT_CPU_FLOOR="0.01") + env = { + "schema_version": 1, + "generated_at": "2026-06-10T00:01:40Z", # +100s vs prev sample below + "clk_tck": 100, + "agents": [{"run_id": 7, "agent": "developer", "runtime_s": 999, "cpu_ticks": 50}], + } + prev_t = orch_mod.parse_generated_at({"generated_at": "2026-06-10T00:00:00Z"}) + prev = {7: AgentSample(cpu_ticks=40, generated_at=prev_t)} + # Δticks=10 over clk_tck=100 -> 0.1 CPU-seconds over 100s -> frac 0.001 < floor. + ev = eval_envelope(env, cfg, prev_agents=prev, prev_failed=None) + hung = [s for s in ev.signals if s.key == ("agent_hung", 7)] + assert len(hung) == 1 and hung[0].active is True + + +def test_agent_hung_skipped_when_cpu_ticks_null(): + cfg = _cfg(WATCHDOG_AGENT_HUNG_MIN="1") + env = { + "generated_at": "2026-06-10T00:01:40Z", + "clk_tck": 100, + "agents": [{"run_id": 8, "runtime_s": 999, "cpu_ticks": None}], + } + prev = {8: AgentSample(cpu_ticks=10, generated_at=0.0)} + ev = eval_envelope(env, cfg, prev_agents=prev, prev_failed=None) + assert [s for s in ev.signals if s.key == ("agent_hung", 8)] == [] + + +def test_agent_busy_not_hung(): + cfg = _cfg(WATCHDOG_AGENT_HUNG_MIN="1", WATCHDOG_AGENT_CPU_FLOOR="0.01") + env = { + "generated_at": "2026-06-10T00:01:40Z", + "clk_tck": 100, + "agents": [{"run_id": 9, "runtime_s": 999, "cpu_ticks": 5000}], + } + prev_t = orch_mod.parse_generated_at({"generated_at": "2026-06-10T00:00:00Z"}) + prev = {9: AgentSample(cpu_ticks=40, generated_at=prev_t)} + # Big Δticks -> high CPU fraction -> not hung. + ev = eval_envelope(env, cfg, prev_agents=prev, prev_failed=None) + assert [s for s in ev.signals if s.key == ("agent_hung", 9)] == [] diff --git a/tests/watchdog/test_never_raise.py b/tests/watchdog/test_never_raise.py new file mode 100644 index 0000000..a385aa0 --- /dev/null +++ b/tests/watchdog/test_never_raise.py @@ -0,0 +1,88 @@ +"""TC-06: three-level never-raise. + +A raising collector (host / containers / deps) degrades ONE signal and the tick +reaches the end collecting the rest; a raising send is swallowed; the daemon +loop survives a raising tick. +""" +from watchdog.config import Config +from watchdog.core import Watchdog + + +class _BoomDocker: + def inspect(self, name): + raise RuntimeError("docker socket blew up") + + +class _Notifier: + def __init__(self): + self.sent = [] + + def send(self, text): + self.sent.append(text) + return True + + +class _BoomNotifier: + def send(self, text): + raise RuntimeError("telegram blew up") + + +def _cfg(**kw): + base = { + "WATCHDOG_TG_BOT_TOKEN": "t", + "WATCHDOG_TG_CHAT_ID": "c", + "WATCHDOG_CONTAINERS": "orchestrator", + } + return Config.from_env({**base, **kw}) + + +def _good_fetch_patch(dog, monkeypatch): + from watchdog.collectors import orch as orch_mod + + env = {"schema_version": 1, "generated_at": "2026-06-10T00:00:00Z", + "clk_tck": 100, "agents": [], "stages": [], + "queue": {"depth": 0, "counts": {"failed": 0}}} + monkeypatch.setattr( + orch_mod, "fetch_metrics", + lambda *a, **k: orch_mod.FetchResult(ok=True, envelope=env), + ) + + +def test_per_source_broken_container_degrades_one_signal(monkeypatch): + notifier = _Notifier() + dog = Watchdog(_cfg(), notifier=notifier, docker=_BoomDocker()) + _good_fetch_patch(dog, monkeypatch) + # Should not raise; tick completes and produces results for other sources. + results = dog.tick() + keys = [getattr(s, "key", None) for _, s in results] + # orch_down evaluated (orch was up -> not active) and container evaluated. + assert "orch_down" in keys + assert ("container_down", "orchestrator") in keys + + +def test_per_send_failure_is_swallowed(monkeypatch): + # A raising notifier must not break the tick (per-send never-raise). + cfg = _cfg(WATCHDOG_MEM_PCT="0") # mem >= 0 always -> force an alert send + dog = Watchdog(cfg, notifier=_BoomNotifier(), docker=_BoomDocker()) + _good_fetch_patch(dog, monkeypatch) + monkeypatch.setattr( + "watchdog.collectors.host.read_mem_used_pct", lambda *a, **k: 50.0 + ) + # Must not raise despite the notifier exploding on a triggered alert. + dog.tick() + + +def test_per_tick_loop_survives_raising_tick(monkeypatch): + # The __main__ run loop must survive a tick that raises (outer never-raise). + from watchdog import __main__ as entry + + cfg = _cfg(WATCHDOG_INTERVAL_S="0") + + class _BoomDog: + def tick(self): + raise RuntimeError("tick blew up") + + monkeypatch.setattr(entry, "Watchdog", lambda c: _BoomDog()) + monkeypatch.setattr(entry.time, "sleep", lambda *_: None) + # max_ticks bounds the loop; it must return cleanly, not propagate. + entry.run(cfg=cfg, max_ticks=3) diff --git a/tests/watchdog/test_notify_isolation.py b/tests/watchdog/test_notify_isolation.py new file mode 100644 index 0000000..f444d70 --- /dev/null +++ b/tests/watchdog/test_notify_isolation.py @@ -0,0 +1,84 @@ +"""TC-10: independent Telegram transport. + +The sidecar sends through its OWN bot_token/chat_id from env and must NOT import +``src.notifications`` or the orchestrator's code (C-1 / BR-8). +""" +import pathlib + +from watchdog import notify as notify_mod +from watchdog.notify import Notifier, send_telegram + + +def test_notify_uses_own_token_and_chat(monkeypatch): + captured = {} + + def _fake_opener(req, timeout=None): + captured["url"] = req.full_url + captured["data"] = req.data + + class _R: + status = 200 + + def getcode(self): + return 200 + + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, *a): + return False + + return _R() + + ok = send_telegram( + "MYTOKEN", "MYCHAT", "hello", opener=_fake_opener, api_base="https://tg.test" + ) + assert ok is True + assert "botMYTOKEN" in captured["url"] + assert b"MYCHAT" in captured["data"] + + +def test_missing_credentials_is_failsafe_no_send(): + # Absent token/chat -> logs and returns False, never raises (fail-safe). + assert send_telegram("", "chat", "x") is False + assert send_telegram("tok", "", "x") is False + + +def test_send_failure_is_swallowed(): + def _boom(req, timeout=None): + raise OSError("network down") + + assert send_telegram("t", "c", "x", opener=_boom) is False + + +def test_notifier_wraps_credentials(monkeypatch): + sent = {} + monkeypatch.setattr( + notify_mod, "send_telegram", + lambda tok, chat, text, timeout: sent.update(tok=tok, chat=chat, text=text) or True, + ) + Notifier("TOK", "CHAT").send("body") + assert sent == {"tok": "TOK", "chat": "CHAT", "text": "body"} + + +def test_watchdog_package_does_not_import_src(): + # No watchdog/*.py file may reference the orchestrator's src package (C-1). + # (Source scan, not sys.modules: the global test conftest imports src.* for + # every test, so a runtime check would be polluted.) + pkg_root = pathlib.Path(notify_mod.__file__).resolve().parent + offenders = [] + for py in pkg_root.rglob("*.py"): + text = py.read_text(encoding="utf-8") + for needle in ("import src", "from src", "src.notifications"): + if needle in text: + offenders.append(f"{py.name}: {needle}") + assert offenders == [], f"watchdog references the orchestrator src: {offenders}" + + +def test_notify_source_has_no_src_notifications_import(): + import inspect + + src = inspect.getsource(notify_mod) + assert "src.notifications" not in src + assert "from src" not in src + assert "import src" not in src diff --git a/tests/watchdog/test_orch_down.py b/tests/watchdog/test_orch_down.py new file mode 100644 index 0000000..498dd21 --- /dev/null +++ b/tests/watchdog/test_orch_down.py @@ -0,0 +1,67 @@ +"""TC-05: orchestrator-down detection. + +A ``/metrics`` timeout / connection-refused / 5xx / unreadable body -> the +``orchestrator_down`` signal -> ALERT "орк не отвечает" once the debounce +threshold of consecutive failures is reached (FR-3). +""" +from watchdog.collectors import orch as orch_mod +from watchdog.config import Config +from watchdog.signals import orch_down_signal + +from .conftest import http_error, make_opener + + +def _cfg(**kw): + return Config.from_env({**{"WATCHDOG_ORCH_DOWN_TICKS": "3"}, **kw}) + + +def test_fetch_timeout_is_not_ok(): + opener = make_opener(exc=TimeoutError("timed out")) + res = orch_mod.fetch_metrics("http://x/metrics", 1.0, opener=opener) + assert res.ok is False + assert res.envelope is None + assert res.error + + +def test_fetch_connection_refused_is_not_ok(): + opener = make_opener(exc=ConnectionRefusedError("refused")) + res = orch_mod.fetch_metrics("http://x/metrics", 1.0, opener=opener) + assert res.ok is False + + +def test_fetch_5xx_is_not_ok(): + opener = make_opener(status=503, body=b"oops") + res = orch_mod.fetch_metrics("http://x/metrics", 1.0, opener=opener) + assert res.ok is False + assert "503" in (res.error or "") + + +def test_fetch_httperror_5xx_is_not_ok(): + opener = make_opener(exc=http_error(502)) + res = orch_mod.fetch_metrics("http://x/metrics", 1.0, opener=opener) + assert res.ok is False + + +def test_fetch_unreadable_body_is_not_ok(): + opener = make_opener(status=200, body=b"not-json{{{") + res = orch_mod.fetch_metrics("http://x/metrics", 1.0, opener=opener) + assert res.ok is False + + +def test_fetch_good_body_is_ok(): + opener = make_opener(status=200, body=b'{"schema_version":1,"stages":[]}') + res = orch_mod.fetch_metrics("http://x/metrics", 1.0, opener=opener) + assert res.ok is True + assert res.envelope["schema_version"] == 1 + + +def test_orch_down_signal_debounce_then_alert(): + cfg = _cfg() + # Single transient failure -> NOT active (does not flap). + assert orch_down_signal(1, cfg, "timeout").active is False + assert orch_down_signal(2, cfg, "timeout").active is False + # K-th consecutive failure -> active alarm. + sig = orch_down_signal(3, cfg, "timeout") + assert sig.active is True + assert sig.key == "orch_down" + assert "не отвечает" in sig.detail diff --git a/tests/watchdog/test_tick_orch_down_integration.py b/tests/watchdog/test_tick_orch_down_integration.py new file mode 100644 index 0000000..2a77d45 --- /dev/null +++ b/tests/watchdog/test_tick_orch_down_integration.py @@ -0,0 +1,106 @@ +"""TC-08: full tick with the orchestrator down (integration). + +With ``/metrics`` failing, the tick must not crash, must still collect host / +containers / deps, must produce EXACTLY ONE ``orchestrator_down`` alert (after +the debounce), suppress within cooldown, and emit recovery on restoration. +""" +from watchdog.collectors import orch as orch_mod +from watchdog.config import Config +from watchdog.core import Watchdog + + +class _Notifier: + def __init__(self): + self.sent = [] + + def send(self, text): + self.sent.append(text) + return True + + +class _StubDocker: + def inspect(self, name): + return {"State": {"Status": "running"}} + + +def _cfg(**kw): + base = { + "WATCHDOG_TG_BOT_TOKEN": "t", + "WATCHDOG_TG_CHAT_ID": "c", + "WATCHDOG_ORCH_DOWN_TICKS": "2", + "WATCHDOG_COOLDOWN_S": "1000", + "WATCHDOG_CONTAINERS": "orchestrator", + } + return Config.from_env({**base, **kw}) + + +def _clock(): + t = {"v": 0.0} + + def now(): + return t["v"] + + return t, now + + +def _down(monkeypatch): + monkeypatch.setattr( + orch_mod, "fetch_metrics", + lambda *a, **k: orch_mod.FetchResult(ok=False, error="timeout"), + ) + + +def _up(monkeypatch): + env = {"schema_version": 1, "generated_at": "2026-06-10T00:00:00Z", + "clk_tck": 100, "agents": [], "stages": [], + "queue": {"depth": 0, "counts": {"failed": 0}}} + monkeypatch.setattr( + orch_mod, "fetch_metrics", + lambda *a, **k: orch_mod.FetchResult(ok=True, envelope=env), + ) + + +def _orch_down_alerts(notifier): + return [m for m in notifier.sent if "не отвечает" in m] + + +def test_tick_orch_down_one_alert_then_throttle_then_recovery(monkeypatch): + notifier = _Notifier() + t, now = _clock() + dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=now) + + _down(monkeypatch) + # tick 1: first failure -> debounced, NOT yet active -> no alert. + dog.tick() + assert _orch_down_alerts(notifier) == [] + + # tick 2: second consecutive failure -> active -> EXACTLY ONE alert. + t["v"] = 30.0 + dog.tick() + assert len(_orch_down_alerts(notifier)) == 1 + + # tick 3: still down, within cooldown -> throttled (no new alert). + t["v"] = 60.0 + dog.tick() + assert len(_orch_down_alerts(notifier)) == 1 + + # restore: orchestrator answers again -> recovery message. + _up(monkeypatch) + t["v"] = 90.0 + dog.tick() + recoveries = [m for m in notifier.sent if "восстановление" in m and "Орк" in m] + assert len(recoveries) == 1 + + +def test_tick_does_not_crash_when_everything_breaks(monkeypatch): + # orch down + docker raising + no deps: tick still completes. + class _BoomDocker: + def inspect(self, name): + raise RuntimeError("boom") + + notifier = _Notifier() + dog = Watchdog(_cfg(), notifier=notifier, docker=_BoomDocker()) + _down(monkeypatch) + dog.tick() # must not raise + dog.tick() + assert len(_orch_down_alerts(notifier)) == 1 diff --git a/watchdog/Dockerfile b/watchdog/Dockerfile new file mode 100644 index 0000000..6b2bbec --- /dev/null +++ b/watchdog/Dockerfile @@ -0,0 +1,28 @@ +# ORCH-100 (FND/F1b): sidecar-watchdog — thin stdlib-only monitoring brain. +# +# A separate, deliberately tiny image (NO pip dependencies — Python 3.12 stdlib +# only, ADR-001 D1): urllib for HTTP/Telegram, a raw HTTP-over-unix-socket client +# for the read-only docker.sock, shutil/proc for host metrics. Kept thin on a +# tight host (C-3); mem_limit is enforced in docker-compose.yml (D2). +# +# The build context is the REPO ROOT (see docker-compose.yml `build: +# context: . / dockerfile: watchdog/Dockerfile`) so we can COPY the watchdog/ +# package. src/** is intentionally NOT copied — the sidecar must not import the +# orchestrator (C-1). +FROM python:3.12-slim + +WORKDIR /app + +# Run as a non-root user; the sidecar only READS (docker.sock :ro, host paths :ro). +RUN useradd -u 1000 -m -d /home/watchdog -s /bin/bash watchdog + +# Copy ONLY the sidecar package (no src/, no requirements — stdlib only). +COPY watchdog/ ./watchdog/ + +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +USER watchdog + +# `python -m watchdog` runs watchdog/__main__.py (the tick loop). +ENTRYPOINT ["python", "-m", "watchdog"] diff --git a/watchdog/__init__.py b/watchdog/__init__.py new file mode 100644 index 0000000..b5a7031 --- /dev/null +++ b/watchdog/__init__.py @@ -0,0 +1,31 @@ +"""ORCH-100 (FND/F1b): sidecar-watchdog — the monitoring brain in a separate container. + +This package is the *brain* half of the domain-0 observability pair. F1a +(ORCH-099, ``src/metrics.py``) exposes a lightweight read-only ``GET /metrics`` +envelope — raw signal only. F1b (this package) is the stateful observer that +reads that envelope, augments it with host / container / dependency probes, runs +every signal through a generalised pure decision function (modelled 1:1 on +``src/disk_watchdog.py::decide_action``) with per-signal in-memory +dedup / throttle / recovery, and emits alerts over its OWN independent Telegram +channel. + +Hard invariants (ADR-001, ``docs/work-items/ORCH-100/06-adr/``): + * The observer is separated from the observed: the runtime is a separate + container (``orchestrator-watchdog``). A hang/crash of the orchestrator makes + the sidecar *louder* (``orchestrator_down``), never silent. + * Strictly read-only to the observed system: ``docker.sock`` is GET-only (and + mounted ``:ro``), no DB writes, no disk writes, no process control + (start/stop/restart/exec) — self-hosting-safe on the shared prod host. + * never-raise on three levels (per-source / per-tick / per-send) + a + ``WATCHDOG_ENABLED`` kill-switch. + * NO import from ``src/**`` — the sidecar must survive a refactor/crash of the + orchestrator process (C-1). + +The highest known ``/metrics`` schema_version this build understands. A higher +value from the orchestrator is tolerated (warning, read the compatible subset), +never a crash (D9). +""" + +KNOWN_SCHEMA_VERSION = 1 + +__all__ = ["KNOWN_SCHEMA_VERSION"] diff --git a/watchdog/__main__.py b/watchdog/__main__.py new file mode 100644 index 0000000..7114c5e --- /dev/null +++ b/watchdog/__main__.py @@ -0,0 +1,75 @@ +"""Sidecar entrypoint: the tick loop with kill-switch + per-tick never-raise (D8). + +Run as ``python -m watchdog`` (the container ``ENTRYPOINT``). The loop: + * honours ``WATCHDOG_ENABLED=false`` -> stays INERT (idle-loops with a log line, + does NOT ``exit``, so ``restart: unless-stopped`` does not spin a restart loop); + * wraps every tick in an outer ``try/except`` so a tick error logs and the daemon + survives (per-tick never-raise); + * logs start / each tick so the container logs prove the sidecar is alive and why + an alert did (not) fire (NFR-7). +""" +from __future__ import annotations + +import logging +import time + +from .config import Config +from .core import Watchdog + +logger = logging.getLogger("watchdog") + + +def _setup_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + +def run(cfg: Config | None = None, max_ticks: int | None = None) -> None: + """Run the tick loop. ``max_ticks`` bounds the loop for tests (``None`` = forever).""" + cfg = cfg or Config.from_env() + + if not cfg.enabled: + logger.info("watchdog: WATCHDOG_ENABLED=false -> inert (idle, no ticks)") + # Idle, not exit: keep the container up so restart-policy does not flap. + ticks = 0 + while max_ticks is None or ticks < max_ticks: + time.sleep(cfg.interval_s) + ticks += 1 + return + + logger.info( + "watchdog started (interval=%ss, metrics=%s, containers=%s, deps=%s, " + "mem_pct=%s, disk_crit=%s)", + cfg.interval_s, + cfg.metrics_url, + cfg.containers, + list(cfg.deps), + cfg.mem_pct, + cfg.disk_crit_enabled, + ) + dog = Watchdog(cfg) + ticks = 0 + while max_ticks is None or ticks < max_ticks: + try: + dispatched = dog.tick() + fired = [ + (a, getattr(s, "key", None)) for a, s in dispatched if a != "none" + ] + logger.info("watchdog tick ok (fired=%s)", fired) + except Exception as e: # noqa: BLE001 - per-tick outer never-raise (D8) + logger.error("watchdog tick error: %s", e) + ticks += 1 + if max_ticks is not None and ticks >= max_ticks: + break + time.sleep(cfg.interval_s) + + +def main() -> None: + _setup_logging() + run() + + +if __name__ == "__main__": + main() diff --git a/watchdog/collectors/__init__.py b/watchdog/collectors/__init__.py new file mode 100644 index 0000000..d14d413 --- /dev/null +++ b/watchdog/collectors/__init__.py @@ -0,0 +1,5 @@ +"""Sidecar collectors: orchestrator ``/metrics``, host, containers, dependencies. + +Each collector is never-raise at the source level (per-source degradation, D8): +a broken source degrades ONE signal and the tick keeps collecting the rest. +""" diff --git a/watchdog/collectors/containers.py b/watchdog/collectors/containers.py new file mode 100644 index 0000000..344bd99 --- /dev/null +++ b/watchdog/collectors/containers.py @@ -0,0 +1,119 @@ +"""Collector: container statuses over a READ-ONLY ``docker.sock`` (D1, D2, FR-5). + +Raw HTTP-over-unix-socket via stdlib (``socket.AF_UNIX`` + +``http.client.HTTPConnection`` subclass) — NO ``docker`` pip package. The client +issues ``GET`` requests ONLY (``GET /containers/json``, +``GET /containers//json``) — it is read-only **by construction**: there is +no method that POSTs / starts / stops / restarts / execs (AC-6, TC-09). The +mount is additionally ``:ro``, a second guarantee. + +``classify_container`` is a pure function (Up / healthy / restarting / exited / +unhealthy) and ``container_alarm`` decides whether the status is alerting — both +testable without a live Docker. +""" +from __future__ import annotations + +import http.client +import json +import logging +import socket + +logger = logging.getLogger("watchdog.collectors.containers") + +# A container is "healthy" (no alarm) only in these states. +_OK_STATES = frozenset({"running", "healthy"}) + + +class _UnixHTTPConnection(http.client.HTTPConnection): + """``HTTPConnection`` over an ``AF_UNIX`` socket (stdlib only, GET-only use).""" + + def __init__(self, sock_path: str, timeout: float): + super().__init__("localhost", timeout=timeout) + self._sock_path = sock_path + + def connect(self) -> None: # noqa: D401 - override + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(self._sock_path) + self.sock = sock + + +class DockerSockReader: + """Read-only Docker API client over the unix socket. + + EXPOSES READ METHODS ONLY (``list_containers`` / ``inspect``); the single + private primitive ``_get`` hard-codes the ``GET`` HTTP method, so no caller + can ever mutate the Docker state (AC-6 / TC-09). never-raise: any socket / + HTTP / parse error degrades to ``None`` / ``[]``. + """ + + def __init__(self, sock_path: str = "/var/run/docker.sock", timeout_s: float = 3.0): + self._sock_path = sock_path + self._timeout = timeout_s + + def _get(self, path: str) -> object | None: + """Issue a single ``GET `` over the socket. never-raise. + + This is the ONLY request primitive and it is GET-only — the read-only + guarantee is structural, not policy. + """ + conn = None + try: + conn = _UnixHTTPConnection(self._sock_path, self._timeout) + conn.request("GET", path) + resp = conn.getresponse() + body = resp.read() + if resp.status >= 400: + logger.warning("watchdog: docker GET %s -> %s", path, resp.status) + return None + return json.loads(body.decode("utf-8", errors="replace")) + except Exception as e: # noqa: BLE001 - docker unreachable -> degrade + logger.warning("watchdog: docker GET %s failed: %s", path, e) + return None + finally: + if conn is not None: + try: + conn.close() + except Exception: # noqa: BLE001 + pass + + def list_containers(self) -> list: + """``GET /containers/json?all=1`` — every container (read-only).""" + data = self._get("/containers/json?all=1") + return data if isinstance(data, list) else [] + + def inspect(self, name: str) -> dict | None: + """``GET /containers//json`` — one container's detail (read-only).""" + data = self._get(f"/containers/{name}/json") + return data if isinstance(data, dict) else None + + +def classify_container(inspect: dict | None) -> str: + """Pure classifier: inspect-JSON -> a coarse status token (D5). + + Returns one of ``running`` / ``healthy`` / ``unhealthy`` / ``restarting`` / + ``exited`` / ``created`` / ``paused`` / ``dead`` / ``unknown``. When a + healthcheck is present its verdict (``healthy`` / ``unhealthy``) takes + precedence over the bare ``running`` state. Never raises. + """ + try: + if not inspect: + return "unknown" + state = inspect.get("State") + if not isinstance(state, dict): + return "unknown" + status = (state.get("Status") or "").strip().lower() + health = state.get("Health") + if isinstance(health, dict): + hstatus = (health.get("Status") or "").strip().lower() + if hstatus in ("healthy", "unhealthy"): + return hstatus + return status or "unknown" + except Exception as e: # noqa: BLE001 - classification must never crash + logger.warning("watchdog: classify_container error: %s", e) + return "unknown" + + +def container_alarm(status: str) -> bool: + """True when ``status`` is NOT a healthy state (restarting/exited/unhealthy/...).""" + return (status or "").strip().lower() not in _OK_STATES diff --git a/watchdog/collectors/deps.py b/watchdog/collectors/deps.py new file mode 100644 index 0000000..5766cd9 --- /dev/null +++ b/watchdog/collectors/deps.py @@ -0,0 +1,51 @@ +"""Collector: external dependency pings — Plane / Gitea / Anthropic (FR-6). + +A light ``GET`` with a short timeout per configured dependency. never-raise: an +unreachable dependency returns ``False`` (a signal for the threshold), never an +exception (D8). Endpoints / timeouts are configured via ``WATCHDOG_DEPS`` (D5); +an empty config means no pings (fail-safe). +""" +from __future__ import annotations + +import logging +import urllib.error +import urllib.request + +logger = logging.getLogger("watchdog.collectors.deps") + + +def ping(url: str, timeout_s: float, *, opener=urllib.request.urlopen) -> bool: + """True when ``url`` answers with a non-5xx HTTP status. never-raise. + + A 4xx still counts as "reachable" (the host is up and responding) — we ping + for liveness, not for auth. ``opener`` is injected so tests never hit the + network. + """ + try: + req = urllib.request.Request(url, method="GET") + with opener(req, timeout=timeout_s) as resp: + status = int(getattr(resp, "status", None) or resp.getcode()) + return status < 500 + except urllib.error.HTTPError as e: + # An HTTP error response still proves the host is reachable, unless 5xx. + return int(getattr(e, "code", 500)) < 500 + except Exception as e: # noqa: BLE001 - unreachable -> down signal, not a crash + logger.warning("watchdog: dep ping %s failed: %s", url, e) + return False + + +def ping_all( + deps: dict[str, str], + timeout_s: float, + *, + opener=urllib.request.urlopen, +) -> dict[str, bool]: + """Ping every configured dependency -> ``{name: reachable}``. never-raise.""" + out: dict[str, bool] = {} + for name, url in deps.items(): + try: + out[name] = ping(url, timeout_s, opener=opener) + except Exception as e: # noqa: BLE001 - one dep degrades, others continue + logger.warning("watchdog: dep %s ping error: %s", name, e) + out[name] = False + return out diff --git a/watchdog/collectors/host.py b/watchdog/collectors/host.py new file mode 100644 index 0000000..86c3dd4 --- /dev/null +++ b/watchdog/collectors/host.py @@ -0,0 +1,75 @@ +"""Collector: host metrics — memory (/proc/meminfo), disk (shutil.disk_usage). + +stdlib-only, the same primitives ``disk_watchdog`` uses (D1). Every reader is +never-raise: a missing path / unreadable proc-file degrades to ``None`` (one +signal skipped), never a tick crash (D8). CPU "hung agent" liveness is computed +from the ``/metrics`` envelope (cpu_ticks), not here. +""" +from __future__ import annotations + +import logging +import shutil + +logger = logging.getLogger("watchdog.collectors.host") + + +def read_mem_used_pct(meminfo_path: str = "/proc/meminfo") -> float | None: + """Host memory used-% from ``/proc/meminfo`` (``MemTotal`` / ``MemAvailable``). + + ``used_pct = (1 - MemAvailable/MemTotal) * 100``. Returns ``None`` on a + missing file / unparseable content / non-Linux (never raises). + """ + try: + fields: dict[str, int] = {} + with open(meminfo_path, "r") as f: + for line in f: + parts = line.split(":") + if len(parts) != 2: + continue + key = parts[0].strip() + val = parts[1].strip().split() + if val: + try: + fields[key] = int(val[0]) # value is in kB + except ValueError: + continue + total = fields.get("MemTotal") + avail = fields.get("MemAvailable") + if not total or avail is None: + return None + used_pct = (1.0 - (avail / total)) * 100.0 + return round(used_pct, 1) + except Exception as e: # noqa: BLE001 - degrade one signal, keep the tick + logger.warning("watchdog: cannot read memory: %s", e) + return None + + +def read_disk_used_pct(path: str) -> float | None: + """Disk used-% for one path via ``shutil.disk_usage`` (1:1 with disk_watchdog). + + Returns ``None`` if the path is missing / unreadable (never raises). + """ + try: + usage = shutil.disk_usage(path) + total = int(usage.total) + if total <= 0: + return None + return round(int(usage.used) / total * 100.0, 1) + except Exception as e: # noqa: BLE001 - skip this path, keep the tick + logger.warning("watchdog: cannot measure disk %s: %s", path, e) + return None + + +def max_disk_used_pct(paths: list[str]) -> tuple[str, float] | None: + """The fullest of ``paths`` as ``(path, used_pct)`` — the worst-case ceiling. + + A path that cannot be measured is skipped; ``None`` if none could be read. + """ + worst: tuple[str, float] | None = None + for p in paths: + pct = read_disk_used_pct(p) + if pct is None: + continue + if worst is None or pct > worst[1]: + worst = (p, pct) + return worst diff --git a/watchdog/collectors/orch.py b/watchdog/collectors/orch.py new file mode 100644 index 0000000..b3f8b95 --- /dev/null +++ b/watchdog/collectors/orch.py @@ -0,0 +1,118 @@ +"""Collector: orchestrator ``GET /metrics`` -> parsed envelope | orchestrator_down. + +The orchestrator runs ``network_mode: host`` on port 8500, so from the +host-network sidecar ``/metrics`` is reachable at ``http://127.0.0.1:8500/metrics`` +(configurable). The body is the F1a versioned envelope +``{schema_version, generated_at, clk_tck, stages[], queue, agents[], cost, +enabled}`` (adr-0030 D2). Parsing is DEFENSIVE (D9): unknown keys are ignored, +a missing optional is not an error, a ``schema_version`` higher than known is +logged (warning) but read as the compatible subset — never a crash. + +A timeout / connection-refused / 5xx / unreadable body is itself the master +alarm signal ``orchestrator_down`` (FR-3), surfaced by ``FetchResult.ok == +False`` — NOT an exception (never-raise per-source, D8). +""" +from __future__ import annotations + +import json +import logging +import urllib.error +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone + +from .. import KNOWN_SCHEMA_VERSION + +logger = logging.getLogger("watchdog.collectors.orch") + + +@dataclass +class FetchResult: + """Outcome of one ``/metrics`` probe. + + ``ok`` is ``True`` only when a 2xx response carried a parseable JSON object. + Any other outcome (timeout / refused / 5xx / unreadable) -> ``ok == False`` + with a human ``error`` -> the ``orchestrator_down`` signal source. + """ + + ok: bool + envelope: dict | None = None + error: str | None = None + + +def parse_envelope(body: str | bytes) -> dict: + """Parse the ``/metrics`` body into a dict — tolerant (D9, TC-11). + + Raises ``ValueError`` only when the body is not a JSON object (that is the + "unreadable body" case the caller maps to ``orchestrator_down``). A valid + object with unknown / missing keys parses cleanly; downstream readers use + ``.get(...)`` with defaults. + """ + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + data = json.loads(body) + if not isinstance(data, dict): + raise ValueError("metrics body is not a JSON object") + return data + + +def check_schema_version(envelope: dict) -> None: + """Warn (never crash) when the orchestrator advertises a newer contract (D9).""" + try: + sv = envelope.get("schema_version") + if isinstance(sv, int) and sv > KNOWN_SCHEMA_VERSION: + logger.warning( + "watchdog: /metrics schema_version=%s > known=%s; reading the " + "compatible subset", + sv, + KNOWN_SCHEMA_VERSION, + ) + except Exception as e: # noqa: BLE001 - tolerance must never crash + logger.warning("watchdog: schema_version check error: %s", e) + + +def fetch_metrics( + url: str, + timeout_s: float, + *, + opener=urllib.request.urlopen, +) -> FetchResult: + """Probe ``GET `` and return a :class:`FetchResult`. never-raise (D8). + + ``opener`` is injected so tests drive timeout / refused / 5xx / good-body + without the network. A 5xx (or any ``HTTPError`` >= 500) is treated as + down; a parseable 2xx object is ``ok``. + """ + try: + with opener(url, timeout=timeout_s) as resp: + status = int(getattr(resp, "status", None) or resp.getcode()) + raw = resp.read() + if status >= 500: + return FetchResult(ok=False, error=f"http {status}") + if status >= 400: + # 4xx is "reachable but refusing" — still not a usable envelope. + return FetchResult(ok=False, error=f"http {status}") + env = parse_envelope(raw) + check_schema_version(env) + return FetchResult(ok=True, envelope=env) + except urllib.error.HTTPError as e: # noqa: PERF203 + return FetchResult(ok=False, error=f"http {getattr(e, 'code', '?')}") + except Exception as e: # noqa: BLE001 - timeout / refused / unreadable -> down + return FetchResult(ok=False, error=str(e) or e.__class__.__name__) + + +def parse_generated_at(envelope: dict) -> float | None: + """Convert the envelope ``generated_at`` ISO-8601 (``...Z``) to epoch seconds. + + Returns ``None`` on a missing / malformed timestamp (never raises) — the + caller then skips the CPU-fraction computation for that tick. + """ + try: + raw = envelope.get("generated_at") + if not raw or not isinstance(raw, str): + return None + dt = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + return dt.timestamp() + except Exception as e: # noqa: BLE001 - tolerant parsing + logger.warning("watchdog: cannot parse generated_at: %s", e) + return None diff --git a/watchdog/config.py b/watchdog/config.py new file mode 100644 index 0000000..4402910 --- /dev/null +++ b/watchdog/config.py @@ -0,0 +1,159 @@ +"""Read ``WATCHDOG_*`` env into a frozen config (thresholds / intervals / tokens / +URLs / kill-switch), with safe defaults (D1/D8, FR-10). + +Every parser is never-raise: a missing / malformed value degrades to its +documented default, the process never crashes on a bad env (the same spirit as +``disk_watchdog.parse_paths``). ``.env.example`` is the canon of the keys. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +def _str(env: dict, key: str, default: str) -> str: + try: + v = env.get(key) + if v is None or not str(v).strip(): + return default + return str(v).strip() + except Exception: # noqa: BLE001 - never break config on a bad env + return default + + +def _int(env: dict, key: str, default: int) -> int: + try: + v = env.get(key) + if v is None or not str(v).strip(): + return default + return int(str(v).strip()) + except Exception: # noqa: BLE001 + return default + + +def _float(env: dict, key: str, default: float) -> float: + try: + v = env.get(key) + if v is None or not str(v).strip(): + return default + return float(str(v).strip()) + except Exception: # noqa: BLE001 + return default + + +def _bool(env: dict, key: str, default: bool) -> bool: + try: + v = env.get(key) + if v is None or not str(v).strip(): + return default + return str(v).strip().lower() in ("1", "true", "yes", "on") + except Exception: # noqa: BLE001 + return default + + +def _csv(env: dict, key: str, default: list[str]) -> list[str]: + try: + v = env.get(key) + if v is None or not str(v).strip(): + return list(default) + out = [p.strip() for p in str(v).split(",") if p.strip()] + return out or list(default) + except Exception: # noqa: BLE001 + return list(default) + + +def _deps(env: dict, key: str) -> dict[str, str]: + """Parse ``name=url,name=url`` dependency pings (FR-6). Empty -> no pings. + + Default is empty (fail-safe: no hardcoded network), the canonical example + URLs live in ``.env.example`` so the operator opts in explicitly. + """ + out: dict[str, str] = {} + try: + raw = env.get(key) + if not raw or not str(raw).strip(): + return out + for pair in str(raw).split(","): + pair = pair.strip() + if not pair or "=" not in pair: + continue + name, _, url = pair.partition("=") + name, url = name.strip(), url.strip() + if name and url: + out[name] = url + except Exception: # noqa: BLE001 + return {} + return out + + +@dataclass(frozen=True) +class Config: + """Immutable sidecar config built from the environment (FR-10).""" + + # -- lifecycle / loop ------------------------------------------------- + enabled: bool = True + interval_s: float = 30.0 + http_timeout_s: float = 5.0 + cooldown_s: float = 1800.0 # re-alert throttle for sustained signals + + # -- orchestrator /metrics ------------------------------------------- + metrics_url: str = "http://127.0.0.1:8500/metrics" + orch_down_ticks: int = 3 # K consecutive failures before orch_down fires + + # -- host ------------------------------------------------------------- + mem_pct: float = 90.0 + disk_paths: list[str] = field(default_factory=lambda: ["/repos", "/app/data"]) + disk_crit_enabled: bool = False # opt-in independent disk ceiling (D6) + disk_crit_pct: float = 97.0 + + # -- agents / queue / stages (derived from the /metrics envelope) ----- + agent_hung_min: float = 20.0 # minutes of runtime before "hung" is considered + agent_cpu_floor: float = 0.01 # CPU fraction below which a long agent is "hung" + stage_stuck_min: float = 120.0 # minutes a task may sit in one stage + queue_depth: int = 20 + + # -- containers (docker.sock, read-only) ------------------------------ + containers: list[str] = field(default_factory=lambda: ["orchestrator"]) + docker_sock: str = "/var/run/docker.sock" + + # -- external dependencies ------------------------------------------- + deps: dict[str, str] = field(default_factory=dict) + + # -- independent Telegram transport ---------------------------------- + tg_bot_token: str = "" + tg_chat_id: str = "" + + # -- derived helpers -------------------------------------------------- + @property + def agent_hung_s(self) -> float: + return self.agent_hung_min * 60.0 + + @property + def stage_stuck_s(self) -> float: + return self.stage_stuck_min * 60.0 + + @classmethod + def from_env(cls, env: dict | None = None) -> "Config": + """Build a Config from ``env`` (defaults to ``os.environ``). never-raise.""" + e = dict(os.environ if env is None else env) + return cls( + enabled=_bool(e, "WATCHDOG_ENABLED", True), + interval_s=_float(e, "WATCHDOG_INTERVAL_S", 30.0), + http_timeout_s=_float(e, "WATCHDOG_HTTP_TIMEOUT_S", 5.0), + cooldown_s=_float(e, "WATCHDOG_COOLDOWN_S", 1800.0), + metrics_url=_str(e, "WATCHDOG_METRICS_URL", "http://127.0.0.1:8500/metrics"), + orch_down_ticks=_int(e, "WATCHDOG_ORCH_DOWN_TICKS", 3), + mem_pct=_float(e, "WATCHDOG_MEM_PCT", 90.0), + disk_paths=_csv(e, "WATCHDOG_DISK_PATHS", ["/repos", "/app/data"]), + disk_crit_enabled=_bool(e, "WATCHDOG_DISK_CRIT_ENABLED", False), + disk_crit_pct=_float(e, "WATCHDOG_DISK_CRIT_PCT", 97.0), + agent_hung_min=_float(e, "WATCHDOG_AGENT_HUNG_MIN", 20.0), + agent_cpu_floor=_float(e, "WATCHDOG_AGENT_CPU_FLOOR", 0.01), + stage_stuck_min=_float(e, "WATCHDOG_STAGE_STUCK_MIN", 120.0), + queue_depth=_int(e, "WATCHDOG_QUEUE_DEPTH", 20), + containers=_csv(e, "WATCHDOG_CONTAINERS", ["orchestrator"]), + docker_sock=_str(e, "WATCHDOG_DOCKER_SOCK", "/var/run/docker.sock"), + deps=_deps(e, "WATCHDOG_DEPS"), + tg_bot_token=_str(e, "WATCHDOG_TG_BOT_TOKEN", ""), + tg_chat_id=_str(e, "WATCHDOG_TG_CHAT_ID", ""), + ) diff --git a/watchdog/core.py b/watchdog/core.py new file mode 100644 index 0000000..726ef32 --- /dev/null +++ b/watchdog/core.py @@ -0,0 +1,183 @@ +"""The sidecar tick orchestration: collect -> evaluate -> decide -> dispatch (D3). + +The ``Watchdog`` owns the cross-tick state the sidecar is responsible for: + * ``_states`` — per signal_key :class:`AlertState` (anti-spam / recovery); + * ``_agents`` — per run_id :class:`AgentSample` (cpu_ticks, generated_at); + * ``_failed`` — last seen ``queue.counts.failed`` (job_failed edge); + * ``_orch_fail`` — consecutive ``/metrics`` failures (orch_down debounce). + +All collection is wrapped per-source and the whole ``tick`` is wrapped per-tick +(never-raise, D8). ``now_provider`` is injectable for deterministic tests. +""" +from __future__ import annotations + +import logging +import time + +from . import decision +from .collectors import containers as containers_mod +from .collectors import deps as deps_mod +from .collectors import host as host_mod +from .collectors import orch as orch_mod +from .config import Config +from .notify import Notifier +from . import signals as signals_mod + +logger = logging.getLogger("watchdog.core") + + +class Watchdog: + """Stateful observer: one ``tick`` collects every source and dispatches alerts.""" + + def __init__( + self, + cfg: Config, + notifier: Notifier | None = None, + docker: containers_mod.DockerSockReader | None = None, + now_provider=None, + ): + self.cfg = cfg + self._now = now_provider or time.time + self._notifier = notifier or Notifier( + cfg.tg_bot_token, cfg.tg_chat_id, cfg.http_timeout_s + ) + self._docker = docker or containers_mod.DockerSockReader( + cfg.docker_sock, cfg.http_timeout_s + ) + # cross-tick state owned by the sidecar + self._states: dict[object, decision.AlertState] = {} + self._agents: dict[object, signals_mod.AgentSample] = {} + self._failed: int | None = None + self._orch_fail: int = 0 + self.last_run_ts: float | None = None + + # -- collection (each source guarded; per-source never-raise) --------- + def _collect_orch(self) -> orch_mod.FetchResult: + try: + return orch_mod.fetch_metrics(self.cfg.metrics_url, self.cfg.http_timeout_s) + except Exception as e: # noqa: BLE001 - treat as down, never crash the tick + logger.warning("watchdog: orch collect error: %s", e) + return orch_mod.FetchResult(ok=False, error=str(e)) + + def _collect_host_mem(self) -> float | None: + try: + return host_mod.read_mem_used_pct() + except Exception as e: # noqa: BLE001 + logger.warning("watchdog: host mem collect error: %s", e) + return None + + def _collect_disk(self) -> tuple | None: + if not self.cfg.disk_crit_enabled: + return None + try: + return host_mod.max_disk_used_pct(self.cfg.disk_paths) + except Exception as e: # noqa: BLE001 + logger.warning("watchdog: disk collect error: %s", e) + return None + + def _collect_containers(self) -> dict: + out: dict[str, str] = {} + for name in self.cfg.containers: + try: + inspect = self._docker.inspect(name) + out[name] = containers_mod.classify_container(inspect) + except Exception as e: # noqa: BLE001 - one container degrades, others continue + logger.warning("watchdog: container %s collect error: %s", name, e) + out[name] = "unknown" + return out + + def _collect_deps(self) -> dict: + try: + return deps_mod.ping_all(self.cfg.deps, self.cfg.http_timeout_s) + except Exception as e: # noqa: BLE001 + logger.warning("watchdog: deps collect error: %s", e) + return {} + + # -- one tick --------------------------------------------------------- + def tick(self) -> list: + """Run one full pass; returns the dispatched ``(action, Signal)`` list. + + Per-source collection is independently guarded so a broken source (ork + down / docker unreachable / dep timeout) degrades ONE signal and the rest + of the tick still runs (D8). The orchestrator being down is itself the + ``orchestrator_down`` signal, not a failed tick (FR-3). + """ + now = self._now() + built: list[signals_mod.Signal] = [] + + # 1) orchestrator /metrics (+ orch_down debounce) + fetch = self._collect_orch() + if fetch.ok and fetch.envelope is not None: + self._orch_fail = 0 + ev = signals_mod.eval_envelope( + fetch.envelope, self.cfg, self._agents, self._failed + ) + self._agents = ev.agent_samples + self._failed = ev.failed_count + built.extend(ev.signals) + else: + self._orch_fail += 1 + built.append( + signals_mod.orch_down_signal(self._orch_fail, self.cfg, fetch.error) + ) + + # 2) host memory + opt-in disk ceiling + built.extend( + signals_mod.host_signals( + self.cfg, self._collect_host_mem(), self._collect_disk() + ) + ) + + # 3) containers (read-only docker.sock) + built.extend(signals_mod.container_signals(self.cfg, self._collect_containers())) + + # 4) external dependency pings + built.extend(signals_mod.dep_signals(self._collect_deps())) + + dispatched = self._dispatch(built, now) + self.last_run_ts = now + return dispatched + + # -- decision + dispatch ---------------------------------------------- + def _dispatch(self, built: list, now: float) -> list: + """Run each signal through ``decide`` and send alert/realert/recovery.""" + results: list = [] + for sig in built: + try: + cooldown = sig.cooldown_s if sig.cooldown_s is not None else self.cfg.cooldown_s + if sig.edge: + # Edge signals (job_failed) fire on each new occurrence and + # keep no sustained state: a fresh empty prev -> ALERT iff active. + prev = decision.AlertState() + else: + prev = self._states.get(sig.key) or decision.AlertState() + action = decision.decide(sig.active, prev, now, cooldown) + if action in (decision.ACTION_ALERT, decision.ACTION_REALERT): + self._send(self._format(sig, action)) + if not sig.edge: + self._states[sig.key] = decision.AlertState( + alerting=True, last_alert_at=now + ) + elif action == decision.ACTION_RECOVERY: + self._send(self._format(sig, action)) + self._states[sig.key] = decision.AlertState( + alerting=False, last_alert_at=None + ) + results.append((action, sig)) + except Exception as e: # noqa: BLE001 - one signal degrades, others dispatch + logger.warning("watchdog: dispatch error for %s: %s", sig.key, e) + return results + + @staticmethod + def _format(sig: signals_mod.Signal, action: str) -> str: + if action == decision.ACTION_RECOVERY: + return f"\U0001f7e2 {sig.title}: восстановление. {sig.detail}" + prefix = "\U0001f534" if action == decision.ACTION_ALERT else "\U0001f501" + return f"{prefix} {sig.title}: {sig.detail}" + + def _send(self, text: str) -> None: + """Best-effort dispatch through the sidecar's own channel. never-raise.""" + try: + self._notifier.send(text) + except Exception as e: # noqa: BLE001 - per-send never-raise (D8) + logger.warning("watchdog: send failed: %s", e) diff --git a/watchdog/decision.py b/watchdog/decision.py new file mode 100644 index 0000000..31b1396 --- /dev/null +++ b/watchdog/decision.py @@ -0,0 +1,63 @@ +"""Generalised pure alert-decision function + in-memory anti-spam state (D4). + +``src/disk_watchdog.py::decide_action`` is hard-wired to ``used_pct >= threshold``. +F1b has many heterogeneous signals (booleans — "orch down", "container +unhealthy"; counters — "job-failed delta"; thresholds — "memory %", "agent hung N +min"), so the *comparison is lifted out* and this function works on an +already-computed boolean ``signal_active``. The set of outcomes, the cooldown / +recovery semantics and the in-memory best-effort state are a strict +generalisation of the disk variant (BRD §BR-9 names it the template). + +``now`` and ``cooldown_s`` are injected so the cooldown / recovery logic is +testable deterministically without a real timer (TC-01…TC-04). +""" +from __future__ import annotations + +from dataclasses import dataclass + +# Decision outcomes — same vocabulary as ``disk_watchdog`` (1:1 semantics). +ACTION_NONE = "none" +ACTION_ALERT = "alert" +ACTION_REALERT = "realert" +ACTION_RECOVERY = "recovery" + + +@dataclass +class AlertState: + """In-memory anti-spam state for one signal key (1:1 with ``PathAlertState``). + + Best-effort: lives only in the daemon (no DB row, no migration). After a + process restart ``alerting`` resets to ``False`` -> a still-standing problem + re-alerts once, which is safe (an early signal, not an SLA; FR-7). + """ + + alerting: bool = False + last_alert_at: float | None = None + + +def decide( + signal_active: bool, + prev: AlertState, + now: float, + cooldown_s: float, +) -> str: + """Pure alert decision — testable without a thread or a real timer (D4). + + Returns one of ``ACTION_{NONE,ALERT,REALERT,RECOVERY}`` as a function of the + current boolean signal, the previous per-key state and the injected clock: + + * not alerting & active -> ALERT (threshold crossed) + * alerting & active & cooldown elapsed -> REALERT (re-alert) + * alerting & active & in cooldown -> NONE (anti-spam) + * alerting & not active -> RECOVERY (back to normal) + * not alerting & not active -> NONE (normal) + """ + if not prev.alerting: + return ACTION_ALERT if signal_active else ACTION_NONE + # prev.alerting is True + if not signal_active: + return ACTION_RECOVERY + last = prev.last_alert_at + if last is None or (now - last) >= cooldown_s: + return ACTION_REALERT + return ACTION_NONE diff --git a/watchdog/notify.py b/watchdog/notify.py new file mode 100644 index 0000000..2fa5690 --- /dev/null +++ b/watchdog/notify.py @@ -0,0 +1,68 @@ +"""Independent Telegram transport for the sidecar (D7, FR-8, BR-8). + +Reads its OWN ``WATCHDOG_TG_BOT_TOKEN`` / ``WATCHDOG_TG_CHAT_ID`` and POSTs via +``urllib`` to ``api.telegram.org``. It is FORBIDDEN to import +``src/notifications.py`` or to use the orchestrator's token / chat / functions — +otherwise a crash or refactor of the orchestrator would drag down the alert +channel (a direct violation of C-1 / BR-8). Missing token/chat -> log and skip +(fail-safe), never raise (NFR-3). +""" +from __future__ import annotations + +import logging +import urllib.parse +import urllib.request + +logger = logging.getLogger("watchdog.notify") + +_TELEGRAM_API = "https://api.telegram.org" + + +def send_telegram( + bot_token: str, + chat_id: str, + text: str, + timeout_s: float = 5.0, + *, + api_base: str = _TELEGRAM_API, + opener=urllib.request.urlopen, +) -> bool: + """Send one Telegram message over the sidecar's own bot. never-raise (D8). + + Returns ``True`` on a delivered message, ``False`` on any failure (missing + credentials, network error, non-2xx). ``opener`` / ``api_base`` are injected + so tests never touch the real network. + """ + if not bot_token or not chat_id: + logger.warning("watchdog: telegram token/chat not configured -> skip send") + return False + try: + url = f"{api_base}/bot{bot_token}/sendMessage" + payload = urllib.parse.urlencode( + { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": "true", + } + ).encode("utf-8") + req = urllib.request.Request(url, data=payload, method="POST") + with opener(req, timeout=timeout_s) as resp: + status = getattr(resp, "status", None) or resp.getcode() + return 200 <= int(status) < 300 + except Exception as e: # noqa: BLE001 - delivery is best-effort + logger.warning("watchdog: telegram send failed: %s", e) + return False + + +class Notifier: + """Thin stateful wrapper binding the sidecar credentials for the tick loop.""" + + def __init__(self, bot_token: str, chat_id: str, timeout_s: float = 5.0): + self._token = bot_token + self._chat = chat_id + self._timeout = timeout_s + + def send(self, text: str) -> bool: + """Best-effort send through the sidecar's own channel (never raises).""" + return send_telegram(self._token, self._chat, text, self._timeout) diff --git a/watchdog/signals.py b/watchdog/signals.py new file mode 100644 index 0000000..6613f66 --- /dev/null +++ b/watchdog/signals.py @@ -0,0 +1,283 @@ +"""Pure signal builders: turn collected raw inputs into ``Signal`` objects (D5). + +A ``Signal`` is ``(key, active, title, detail, edge)``. ``key`` identifies the +signal for per-key anti-spam state: a scalar (``"orch_down"``, ``"host_mem"``) +or a tuple for per-entity signals (``("agent_hung", run_id)``, +``("container_down", name)``, ``("stage_stuck", work_item)``, +``("dep_down", name)``). + +These builders are PURE — given the envelope / host readings / prev-sample state +they return signals + the next sample state, with no I/O — so the whole decision +surface is unit-testable without a container, a socket or a timer (TC-01…TC-11). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from .collectors import containers as containers_mod +from .collectors import orch as orch_mod +from .config import Config + +logger = logging.getLogger("watchdog.signals") + + +@dataclass +class Signal: + """One evaluated signal heading into the decision function. + + ``edge`` marks event-style signals (e.g. ``job_failed``) that fire on each + new occurrence and have no sustained "recovery": the dispatcher does not + persist alerting state for them. + """ + + key: object + active: bool + title: str + detail: str + edge: bool = False + cooldown_s: float | None = None # per-signal override of the global cooldown + + +@dataclass +class AgentSample: + """Previous ``(cpu_ticks, generated_at_epoch)`` for one running agent (D5).""" + + cpu_ticks: int + generated_at: float + + +@dataclass +class EnvelopeEval: + """Result of evaluating the ``/metrics`` envelope: signals + carried state.""" + + signals: list = field(default_factory=list) + agent_samples: dict = field(default_factory=dict) # run_id -> AgentSample + failed_count: int | None = None + + +def _cpu_fraction( + cur_ticks: int, + cur_gen: float, + prev: AgentSample, + clk_tck: int, +) -> float | None: + """CPU fraction of one agent across two ``/metrics`` polls (D5). + + ``frac = (Δticks / clk_tck) / Δseconds``. Returns ``None`` if the deltas are + not usable (no wall-time elapsed, non-positive clk_tck) so a degenerate + sample never produces a false "hung" verdict. + """ + try: + dt = cur_gen - prev.generated_at + if dt <= 0 or not clk_tck or clk_tck <= 0: + return None + cpu_seconds = (cur_ticks - prev.cpu_ticks) / clk_tck + if cpu_seconds < 0: + return None + return cpu_seconds / dt + except Exception as e: # noqa: BLE001 - degenerate sample, no verdict + logger.warning("watchdog: cpu_fraction error: %s", e) + return None + + +def eval_envelope( + envelope: dict, + cfg: Config, + prev_agents: dict, + prev_failed: int | None, +) -> EnvelopeEval: + """Derive agent_hung / stage_stuck / job_failed / queue_depth signals (D5). + + Pure: no I/O. ``prev_agents`` (run_id -> :class:`AgentSample`) and + ``prev_failed`` carry the cross-tick state the sidecar owns; the returned + :class:`EnvelopeEval` includes the NEXT state to persist. never-raise: a bad + sub-section degrades that family of signals, the rest still evaluate. + """ + out = EnvelopeEval() + if not isinstance(envelope, dict): + out.agent_samples = dict(prev_agents) + out.failed_count = prev_failed + return out + + clk_tck = envelope.get("clk_tck") + gen_at = orch_mod.parse_generated_at(envelope) + + # -- agent_hung (needs two polls; per run_id) ------------------------- + new_samples: dict = {} + try: + for a in envelope.get("agents") or []: + run_id = a.get("run_id") + cpu_ticks = a.get("cpu_ticks") + runtime_s = a.get("runtime_s") + if run_id is None: + continue + if cpu_ticks is None or gen_at is None: + # pid dead / non-Linux / no timestamp -> cannot judge; skip. + continue + new_samples[run_id] = AgentSample(int(cpu_ticks), gen_at) + prev = prev_agents.get(run_id) + if prev is None or not isinstance(clk_tck, int): + continue + frac = _cpu_fraction(int(cpu_ticks), gen_at, prev, clk_tck) + if frac is None or runtime_s is None: + continue + hung = (runtime_s > cfg.agent_hung_s) and (frac < cfg.agent_cpu_floor) + if hung: + out.signals.append( + Signal( + key=("agent_hung", run_id), + active=True, + title="Агент завис", + detail=( + f"agent={a.get('agent')} run_id={run_id} " + f"runtime={int(runtime_s)}s cpu={frac:.4f} " + f"(< {cfg.agent_cpu_floor})" + ), + ) + ) + except Exception as e: # noqa: BLE001 - degrade agent family only + logger.warning("watchdog: eval agents error: %s", e) + out.agent_samples = new_samples + + # -- stage_stuck (per work_item) ------------------------------------- + try: + for s in envelope.get("stages") or []: + age = s.get("age_in_stage_s") + wi = s.get("work_item") + if age is None or wi is None: + continue + if age > cfg.stage_stuck_s: + out.signals.append( + Signal( + key=("stage_stuck", wi), + active=True, + title="Стадия застряла", + detail=( + f"{wi} в стадии {s.get('stage')} уже {int(age)}s " + f"(порог {int(cfg.stage_stuck_s)}s)" + ), + ) + ) + except Exception as e: # noqa: BLE001 + logger.warning("watchdog: eval stages error: %s", e) + + # -- queue depth + job_failed (edge) --------------------------------- + failed_now: int | None = prev_failed + try: + queue = envelope.get("queue") or {} + depth = queue.get("depth") + if isinstance(depth, int) and depth >= cfg.queue_depth: + out.signals.append( + Signal( + key="queue_depth", + active=True, + title="Очередь растёт", + detail=f"глубина очереди {depth} (порог {cfg.queue_depth})", + ) + ) + counts = queue.get("counts") or {} + failed = counts.get("failed") + if isinstance(failed, int): + failed_now = failed + if prev_failed is not None and failed > prev_failed: + out.signals.append( + Signal( + key="job_failed", + active=True, + title="Job упал", + detail=( + f"failed-джобов стало {failed} " + f"(было {prev_failed}, +{failed - prev_failed})" + ), + edge=True, + ) + ) + except Exception as e: # noqa: BLE001 + logger.warning("watchdog: eval queue error: %s", e) + out.failed_count = failed_now + + return out + + +def host_signals(cfg: Config, mem_pct: float | None, disk: tuple | None) -> list: + """Build host memory + opt-in disk-ceiling signals (D5/D6). Pure.""" + sigs: list = [] + if mem_pct is not None: + sigs.append( + Signal( + key="host_mem", + active=mem_pct >= cfg.mem_pct, + title="Память хоста", + detail=f"память хоста {mem_pct}% (порог {cfg.mem_pct}%)", + ) + ) + # Disk ceiling is OPT-IN (D6): disk_watchdog (ORCH-063) owns the 85% alert; + # the sidecar only carries an independent HIGHER ceiling when explicitly + # enabled, so there is no double-alert on the same fill event (FR-9/AC-5). + if cfg.disk_crit_enabled and disk is not None: + path, pct = disk + sigs.append( + Signal( + key="host_disk_crit", + active=pct >= cfg.disk_crit_pct, + title="Диск (критический потолок)", + detail=( + f"диск {path} {pct}% (критический потолок {cfg.disk_crit_pct}%, " + f"независимый канал sidecar)" + ), + ) + ) + return sigs + + +def container_signals(cfg: Config, statuses: dict) -> list: + """Build per-container down signals from ``{name: status}``. Pure.""" + sigs: list = [] + for name, status in statuses.items(): + sigs.append( + Signal( + key=("container_down", name), + active=containers_mod.container_alarm(status), + title="Контейнер не в норме", + detail=f"контейнер {name}: статус '{status}'", + ) + ) + return sigs + + +def dep_signals(reachability: dict) -> list: + """Build per-dependency down signals from ``{name: reachable}``. Pure.""" + sigs: list = [] + for name, reachable in reachability.items(): + sigs.append( + Signal( + key=("dep_down", name), + active=not reachable, + title="Зависимость недоступна", + detail=f"зависимость {name} не отвечает", + ) + ) + return sigs + + +def orch_down_signal(consecutive_failures: int, cfg: Config, error: str | None) -> Signal: + """The master ``orchestrator_down`` signal (FR-3). + + Active once ``/metrics`` has failed ``orch_down_ticks`` times in a row — a + single transient hiccup does not flap. The text explicitly notes that the + in-process guards (disk / reaper / reconciler) are dead too, so the operator + knows to check the host directly (D6). + """ + active = consecutive_failures >= cfg.orch_down_ticks + return Signal( + key="orch_down", + active=active, + title="Орк не отвечает", + detail=( + f"GET /metrics не отвечает {consecutive_failures} тик(ов) подряд " + f"(порог {cfg.orch_down_ticks}): {error or 'недоступен'}. " + f"In-process стражи (disk/reaper/reconciler) тоже мертвы — проверьте " + f"хост (вкл. диск) и контейнер orchestrator." + ), + ) From 93cf2732a23e5b0b1a69f93122693786bcf2bcde Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 09:12:04 +0300 Subject: [PATCH 05/10] reviewer(ET): auto-commit from reviewer run_id=567 --- docs/work-items/ORCH-100/12-review.md | 117 ++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/work-items/ORCH-100/12-review.md diff --git a/docs/work-items/ORCH-100/12-review.md b/docs/work-items/ORCH-100/12-review.md new file mode 100644 index 0000000..2b5b9e7 --- /dev/null +++ b/docs/work-items/ORCH-100/12-review.md @@ -0,0 +1,117 @@ +--- +verdict: APPROVED +work_item: ORCH-100 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-100 +version: 1 +--- + +# Review ORCH-100 — FND/F1b: sidecar-watchdog + +## Summary + +Аддитивная реализация sidecar-наблюдателя в отдельном контейнере `orchestrator-watchdog` +(папка `watchdog/`, тонкий Python-3.12-stdlib-only демон). Проверено по 4 осям. Реализация +**точно** соответствует ТЗ (FR-1…FR-11) и ADR-001 (D1…D9): отдельный контейнер, толерантный +парсинг `/metrics`, debounce `orch_down`, read-only `docker.sock` (GET-only по построению), +обобщённая чистая `decide`, независимый Telegram-канал, структурный анти-дубль диск-алерта (D6), +трёхуровневый never-raise, kill-switch. + +**Корневой инвариант соблюдён:** PR не трогает `src/**` ни одной строкой (подтверждено +`git diff --stat -- 'src/**'` → пусто) ⇒ `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / +machine-verdict ключи / схема БД орка — байт-в-байт прежние. Документация обновлена +исчерпывающе. 66 тестов `tests/watchdog/` зелёные. + +**Вердикт: APPROVED.** P0/P1, относящихся к этому PR, нет. Ниже — P2-замечания (не блокируют) +и одна эскалация по pre-existing красному тесту вне области F1b. + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- [ ] **Заявление «полный регресс `tests/ -q` зелёный» неточно (CHANGELOG / AC-7).** Полный прогон + `pytest tests/` даёт **1 падение**: `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail`. + **Важно:** этот тест **байт-в-байт идентичен `origin/main`** (`git diff origin/main...HEAD -- + tests/test_queue.py` → пусто), а PR не меняет `src/**` — падение **pre-existing на main**, НЕ + вызвано ORCH-100 и **структурно неустранимо в рамках F1b** (ТЗ §2 прямо запрещает правки `src/**`). + Поэтому это **не основание для REQUEST_CHANGES** этого PR. Действие: скорректировать формулировку + «полный tests/ зелёный» → «`tests/watchdog/` зелёные (66); один pre-existing красный тест на main + вне области F1b» и завести отдельную баг-задачу (см. Escalation). Источник: AC-7 + (`03-acceptance-criteria.md`), CHANGELOG-запись ORCH-100. +- [ ] **FR-4: host inode/CPU не реализованы как сигналы.** ТЗ FR-4 упоминает «диск (% и, где + доступно, inode), память, CPU»; ADR-001 D5 (реестр сигналов) сузил host-метрики до `host_mem` + + opt-in `host_disk_crit`, и этот реестр реализован полностью. Host-CPU (loadavg) и inode в сигналы + не превращены — `watchdog/collectors/host.py` их не собирает (host-CPU/«завис» покрыт через + `agent_hung` из `/metrics`). Это документированное сужение на стадии архитектуры (FR-4 + формулирует inode как «где доступно»), не нарушение. Замечание: docstring ADR D3 для `host.py` + упоминает «CPU (loadavg)», которого в реализации нет — расхождение комментария и кода; либо + реализовать host-CPU/inode-сигнал, либо снять упоминание из docstring D3. Источник: `02-trz.md` + FR-4, `ADR-001` D3/D5. + +### P3 — Nice-to-have +- [ ] **CLAUDE.md не обновлён.** Паспорт проекта не получил запись о F1b. Прецедент: парная задача + F1a (ORCH-099) также отсутствует в CLAUDE.md (grep → 0) — это семейство наблюдаемости в паспорте + не трекается, а золотой архитектурный док (`docs/architecture/README.md`) покрывает F1b + исчерпывающе (компонентная строка + полная секция §«Sidecar-watchdog F1b»). Опционально для + единообразия с другими операционными демонами (`disk_watchdog`/`reaper`) — добавить краткую + TL;DR-строку. +- [ ] **`watchdog/collectors/containers.py::DockerSockReader.list_containers` не вызывается** в + `core.tick` (используется только `inspect(name)` по списку `cfg.containers`). Публичный read-метод + оставлен для полноты API/тестов — безвреден; при желании отметить как explicit-API или удалить. + +## Документация + +**Обновлена исчерпывающе — golden source синхронизирован с кодом:** +- ✅ `docs/architecture/README.md` — новая компонентная строка (Sidecar-watchdog F1b) + полная + секция «## Sidecar-watchdog F1b (ORCH-100 — design)» + перекрёстная ссылка в секции F1a. +- ✅ `CHANGELOG.md` — детальная запись F1b (стек D1 / топология D2 / decide D4 / реестр сигналов D5 / + анти-дубль D6 / транспорт D7 / never-raise D8). +- ✅ `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md` + сквозной + `docs/architecture/adr/adr-0033-sidecar-watchdog.md` (оба с корректным frontmatter). +- ✅ `docs/work-items/ORCH-100/07-infra-requirements.md` — разовое инфра-предусловие (сервис в + compose, bot/chat watchdog, `.env.watchdog`, первый запуск). +- ✅ `.env.example` — канон всех `WATCHDOG_*` ключей, **без реальных секретов** (токены пустые). +- ⚠️ **CLAUDE.md** — не обновлён (P3, см. выше; прецедент F1a — допустимо). +- ✅ **README «Известные ограничения» (ось ORCH-079):** F1b — новая способность (внешний + наблюдатель), **ни один** из 3 открытых пунктов витрины не закрывается этим PR ⇒ обновления + обзорной витрины не требуется (ось удовлетворена). + +**Поскольку `src/**` НЕ изменён, P0 «src изменён, документация не обновлена» не активируется**; +при этом документация всё равно обновлена сверх минимума. + +## Проверки инвариантов (явно) + +- `git diff origin/main...HEAD --stat -- 'src/**'` → **пусто** (src нетронут; STAGE_TRANSITIONS / + QG_CHECKS / check_* / схема БД — байт-в-байт). +- `docker.sock` смонтирован `:ro` (compose) И код GET-only по построению (`_get` хардкодит `GET`, + нет ни одного мутирующего метода) — двойная гарантия read-only (AC-6). +- Нет импорта `src/**` из `watchdog/**` (C-1 / BR-8) — независимый Telegram-транспорт со своими + токенами; падение орка не утянет алерт-канал. +- never-raise: per-source (коллекторы), per-tick (`__main__` + `core._dispatch`), per-send + (`notify`) — все три уровня присутствуют. +- kill-switch `WATCHDOG_ENABLED=false` → idle-loop (НЕ exit) — restart-policy не крутит петлю. +- `mem_limit: 128m` + `mem_reservation: 32m`; stdlib-only (нет requirements/pip-дерева) — тонкость + C-3 соблюдена. +- Багфикс-трек (ORCH-019 BR-4): задача — `feat`/FND, не `Bug` ⇒ требование регресс-теста-фиксатора + не применяется. + +## Escalation + +- **Pre-existing красный тест на `main`:** `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail` + падает стабильно (3/3 изолированных прогона) и идентичен `origin/main`. Он **не вызван** ORCH-100 + и **неустраним в области F1b** (правки `src/**` запрещены ТЗ §2). Однако он **завалит** downstream + merge-gate re-test (ORCH-043 гоняет `pytest tests/` на догнанной ветке) и нарушает AC-7 «полный + tests/ зелёный» на уровне репозитория. **Рекомендация:** Owner/планировщику завести отдельную + баг-задачу (метка `Bug`, маршрут ORCH-019) на починку `test_finalize_job_requeue_then_fail` в + `src/**`; без неё прод-выкат ORCH-100 упрётся в merge-gate по причине, не относящейся к F1b. Это + эскалация, а не finding против данного PR. From d61b583dad659edba476231a6c40319251f77c46 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 09:16:51 +0300 Subject: [PATCH 06/10] tester(ET): auto-commit from tester run_id=568 --- docs/work-items/ORCH-100/13-test-report.md | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/work-items/ORCH-100/13-test-report.md diff --git a/docs/work-items/ORCH-100/13-test-report.md b/docs/work-items/ORCH-100/13-test-report.md new file mode 100644 index 0000000..00e7396 --- /dev/null +++ b/docs/work-items/ORCH-100/13-test-report.md @@ -0,0 +1,123 @@ +--- +result: FAIL # PASS | FAIL — машинный вердикт, UPPERCASE +work_item: ORCH-100 +stage: testing +author_agent: tester +status: fail +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-100 +--- + +# Test Report — ORCH-100 — FND/F1b: sidecar-watchdog + +> Машинный вердикт читается ТОЛЬКО из frontmatter (`result:`, UPPERCASE). Негативный токен +> (`FAIL`) — авторитетен. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8) +- Дата: 2026-06-10 +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-100-fnd-f1b-sidecar-watchdog` + (ветка `feature/ORCH-100-fnd-f1b-sidecar-watchdog`) — тесты прогнаны из рабочего дерева + именно этой задачи, НЕ из общего `/repos/orchestrator`. + +## Smoke API (read-only) +| Эндпоинт | Результат | +|----------|-----------| +| `GET /health` | OK — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | OK — задача `ORCH-100` (id 85) в стадии `testing`, активный набор задач отдан | +| `GET /queue` | OK — блоки `serial_gate` **И** `auto_labels` присутствуют в полезной нагрузке (анти-регресс смока ORCH-088 соблюдён) | +| `GET /metrics` (ORCH-099, потребляется F1b) | OK — конверт `{schema_version=1, generated_at, clk_tck, stages, queue, agents, cost}` цел | + +Smoke зелёный, прод-контейнер не трогался (только чтение). + +## Результаты + +### Профильная сюита F1b — `tests/watchdog/` +**66 passed** (0 failed). Полностью зелёная — это собственно поставка F1b (отдельный +sidecar-демон: решающая функция, парсинг `/metrics`, детект orchestrator-down, never-raise, +read-only docker, изолированный транспорт, kill-switch, compose-инвариант, анти-дубль диск-алерта). + +### Полный регресс орка — `pytest tests/` +**1 failed, 1616 passed** (`tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail`). + +``` +tests/test_queue.py:189: in test_finalize_job_requeue_then_fail + assert get_job(jid)["status"] == "failed" # 2 >= 2 -> failed +E AssertionError: assert 'queued' == 'failed' +E - failed +E + queued +``` + +#### Классификация падения (верифицировано независимо) +- `git diff origin/main...HEAD --stat -- 'src/**'` → **пусто** ⇒ ORCH-100 не меняет `src/**` + ни одной строкой. +- `git diff origin/main...HEAD -- tests/test_queue.py` → **пусто** ⇒ файл теста байт-в-байт + идентичен `origin/main`. +- Падение **детерминированно** и воспроизводится в изоляции (2/2 прогона), не артефакт + порядка тестов. +- **Вывод:** падение — **pre-existing на `main`**, НЕ вызвано F1b и **структурно неустранимо + в рамках ORCH-100** (ТЗ §2 прямо запрещает правки `src/**`). Это реальное, обоснованное + красное (тест ловит расхождение логики requeue→finalize с transient-backoff в + `launcher`), а не флап окружения. + +## Сопоставление с тест-планом (`04-test-plan.yaml`) + +| TC ID | Описание | Тест-функция / модуль | Результат | +|-------|----------|------------------------|-----------| +| TC-01 | not-alerting & ≥threshold → ALERT | `test_decision.py::test_tc01_not_alerting_active_alerts` (+ inactive→none) | PASS | +| TC-02 | alerting & cooldown НЕ истёк → NONE (throttle) | `test_decision.py::test_tc02_alerting_active_in_cooldown_is_none` | PASS | +| TC-03 | alerting & cooldown истёк → REALERT | `test_decision.py::test_tc03_alerting_active_cooldown_elapsed_realerts` | PASS | +| TC-04 | alerting & вернулось ниже порога → RECOVERY | `test_decision.py::test_tc04_alerting_recovers_when_inactive` | PASS | +| TC-05 | детект orchestrator-down (timeout/refused/5xx/нечит. тело) → ALERT + debounce | `test_orch_down.py` (7 тестов) | PASS | +| TC-06 | never-raise per-source/per-tick/per-send | `test_never_raise.py` (3 теста) | PASS | +| TC-07 | kill-switch инертен; пороги/интервалы/таймауты из env | `test_config_killswitch.py` (4 теста) | PASS | +| TC-08 | интеграция: полный тик при down орке (1 алерт + throttle + recovery; всё ломается — тик не падает) | `test_tick_orch_down_integration.py` (2 теста) | PASS | +| TC-09 | self-hosting safety: docker GET-only, без start/stop/restart/exec | `test_docker_readonly.py` (5 тестов) | PASS | +| TC-10 | независимый транспорт: свои токен/chat, без импорта `src/notifications.py`/`src` | `test_notify_isolation.py` (6 тестов) | PASS | +| TC-11 | толерантность `/metrics`: неизвестное поле игнор, опц. отсутствие ок, рост schema_version → warning | `test_metrics_parse.py` (10 тестов) | PASS | +| TC-12 | compose-инвариант: отдельный сервис `orchestrator-watchdog`, build `watchdog/`, restart, mem_limit, docker.sock `:ro` | `test_compose_service.py` (7 тестов) | PASS | +| TC-13 | анти-дубль диск-алерта (согласовано с ORCH-063) | `test_disk_alert_dedup.py` (3 теста) | PASS | +| **TC-14** | **регресс орка: полный `pytest tests/` зелёный; `src/**` не изменён; `/metrics`-контракт цел** | `tests/` | **FAIL** | + +**TC-14 детально (смешанный исход → FAIL по букве):** +- ✅ `src/**` не изменён (диск-проверка пуста). +- ✅ `/metrics`-контракт (ORCH-099) не сломан (конверт цел, смок зелёный). +- ❌ полный `pytest tests/` **НЕ зелёный** — 1 pre-existing красное (см. выше). +- Поскольку TC-14 ожидает **PASS** по условию «полный `pytest tests/` зелёный», а суммарный + прогон репозитория красный — **TC-14 = FAIL**. Это единственный FAIL; все 13 профильных + TC (TC-01…TC-13) зелёные. + +## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) + +| AC | Покрытие | Результат | +|----|----------|-----------| +| AC-1 — sidecar отдельным контейнером собирает 4 источника | TC-12 + коллекторы host/deps/docker/metrics | PASS | +| AC-2 — пороговый алерт: один на пересечение + throttle + recovery + орк-down | TC-01…TC-05 | PASS | +| AC-3 — изоляция: падение орка не роняет sidecar | TC-05/06/08 | PASS | +| AC-4 — тонкость, kill-switch, конфиг-пороги | TC-07/12 | PASS | +| AC-5 — анти-дубль диск-алерта (ORCH-063) | TC-13 | PASS | +| AC-6 — self-hosting safety (только чтение/алерт) | TC-09/10 | PASS | +| AC-7 — инфра-доки + `pytest` зелёный + docs/CHANGELOG | `07-infra-requirements.md` ✅, CHANGELOG ✅, доки ✅; **но полный `pytest tests/` НЕ зелёный** | **FAIL** (под-условие «полный `tests/` зелёный» нарушено) | + +## Эскалация +Падение `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail` — **обоснованный +pre-existing FAIL на `main`**, не относящийся к F1b и **неустранимый в области ORCH-100** (правки +`src/**` запрещены ТЗ §2). Откат на `development` в рамках ORCH-100 его **не починит** (разработчик +F1b не вправе трогать `src/**`). **Рекомендация (зеркало эскалации reviewer'а в `12-review.md`):** +Owner/планировщику завести **отдельную баг-задачу** (метка `Bug`, маршрут ORCH-019) на починку +`test_finalize_job_requeue_then_fail` в `src/launcher`-логике requeue→finalize. Без неё прод-выкат +ORCH-100 всё равно упрётся в downstream merge-gate re-test (ORCH-043 гоняет `pytest tests/` на +догнанной ветке) по причине, не относящейся к F1b. + +## Итог +**FAIL.** + +- Поставка F1b сама по себе полностью зелёная: `tests/watchdog/` 66/66 PASS, smoke (`/health`, + `/status`, `/queue`, `/metrics`) зелёный, все профильные TC-01…TC-13 + AC-1…AC-6 — PASS. +- Однако **полный регресс репозитория `pytest tests/` красный** (1 pre-existing fail, 1616 pass) + → TC-14 и под-условие AC-7 «полный `tests/` зелёный» нарушены → машинный вердикт `result: FAIL`. +- Тестер не помечает красный регресс зелёным (`❌ не подгонять тесты под код`). Падение — + exogenous к F1b и требует отдельной баг-задачи (эскалация выше), а не доработки ORCH-100. From 318bae747267c3aa198382f231c6d1fe4c49548d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 09:21:12 +0300 Subject: [PATCH 07/10] fix(test): isolate settings.runs_dir in conftest to stop ambient prod-log pollution (ORCH-100) test_queue.py::TestRetry::test_finalize_job_requeue_then_fail failed in the self-hosting environment because launcher._finalize_job classifies a non-zero exit by reading the tail of /.log. settings.runs_dir defaults to the live prod dir /app/data/runs, which on the host holds REAL accumulated agent logs; a real 2.log containing "429" flips the expected 'permanent' classification to 'transient', requeueing the job instead of marking it 'failed'. This is ambient prod pollution, not a code fault. Add an autouse _isolate_runs_dir fixture (mirroring _no_telegram / _disable_merge_verify) that redirects settings.runs_dir to a per-test tmp dir so _run_log_path() resolves to a non-existent file and classify_log_file() returns the documented 'permanent' default. Full suite: 1617 passed. src/** untouched. Refs: ORCH-100 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + tests/conftest.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea854ab..8b65ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## [Unreleased] - **FND/F1b: sidecar-watchdog — мозг мониторинга в отдельном контейнере** (ORCH-100, `feat`): новая папка `watchdog/` (тонкий **Python-3.12-stdlib-only** демон) + сервис `orchestrator-watchdog` в `docker-compose.yml` (`network_mode: host`, read-only `docker.sock`, `mem_limit: 128m`). Вторая половина пары наблюдаемости домена 0: F1a (ORCH-099) отдаёт `GET /metrics` (сырьё), F1b — **мозг**, который это сырьё читает, дополняет внешними сигналами (хост/контейнеры/зависимости) и превращает в **алерты** через **собственный** независимый Telegram-канал. **`src/**` НЕ изменён** — F1b потребитель `/metrics`; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД орка — байт-в-байт. Аддитивно, под kill-switch `WATCHDOG_ENABLED`, строго read-only к наблюдаемому (self-hosting-безопасно). ADR: `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`, сквозной `docs/architecture/adr/adr-0033-sidecar-watchdog.md`. + - **fix(test): изоляция `settings.runs_dir` в conftest** — устранена амбиентная prod-зависимость, валившая `test_queue.py::TestRetry::test_finalize_job_requeue_then_fail` в self-hosting-окружении (TC-14 «full tests/ regression green»). `launcher._finalize_job` классифицирует падение по хвосту `/.log`; `runs_dir` по умолчанию = живой prod-каталог `/app/data/runs`, где на хосте накоплены РЕАЛЬНЫЕ логи агентов (`2.log` содержит `429` → 'transient'), поэтому тест с литеральным `run_id=2` читал чужой prod-лог и получал requeue вместо `failed`. Новый autouse-фикстур `_isolate_runs_dir` в `tests/conftest.py` (по образцу `_no_telegram`/`_disable_merge_verify`) перенаправляет `runs_dir` в пер-тестовый tmp → `_run_log_path()` указывает на несуществующий файл → `classify_log_file()` отдаёт документированный дефолт 'permanent'. Детерминизм всей сюты восстановлен (1617 passed); `src/**` не тронут. - **Стек (D1):** Python 3.12 stdlib-only на `python:3.12-slim` — `urllib` (HTTP `/metrics` + пинги + Telegram POST), сырой HTTP-over-unix-socket для read-only `docker.sock` (БЕЗ pip-пакета `docker`), `shutil.disk_usage`/`/proc/meminfo` для хоста. Нет дерева зависимостей (тонкость, C-3). Отдельный образ `watchdog/Dockerfile` (build-контекст = корень репо; `src/**` НЕ копируется — изоляция C-1). - **Топология (D2):** сервис собирается из `watchdog/Dockerfile`, `restart: unless-stopped` (самовосстановление), `network_mode: host` → `/metrics` достижим как `http://127.0.0.1:8500/metrics`; `docker.sock` смонтирован `:ro` И код GET-only (двойная гарантия read-only); хост-пути bind-mount `:ro`; `mem_limit: 128m`+`mem_reservation: 32m`. `env_file` опционален (`required: false`) → отсутствие `.env.watchdog` НЕ ломает `docker compose up` прод-орка. Деплой watchdog поднимает ТОЛЬКО его — прод `orchestrator` не пересобирается/не рестартится. - **Обобщённая чистая решающая функция (D4):** `watchdog/decision.py::decide(signal_active, prev, now, cooldown_s) -> alert|realert|recovery|none` — строгая генерализация `disk_watchdog.decide_action` (булев `signal_active` вместо `used_pct >= threshold`), per-signal in-memory `AlertState` (анти-спам/recovery, рестарт сбрасывает → корректный повторный алерт стоящей проблемы). diff --git a/tests/conftest.py b/tests/conftest.py index 216fe0f..aec232e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,6 +77,34 @@ def _reset_webhook_secrets(monkeypatch): yield +@pytest.fixture(autouse=True) +def _isolate_runs_dir(monkeypatch, tmp_path): + """ORCH-100: point settings.runs_dir at a per-test tmp dir in ALL tests. + + Background: ``launcher._run_log_path(run_id)`` resolves to + ``/.log`` and, on a non-zero exit, + ``_finalize_job`` classifies the failure by reading the *tail of that log* + (transient 429/overload/timeout -> backoff-requeue; permanent -> attempts + requeue then 'failed'). settings.runs_dir defaults to the live prod dir + ``/app/data/runs``, which on the self-hosting host holds REAL accumulated + agent logs (1.log, 2.log, ...). Tests that exercise the finalize path with a + small literal run_id (e.g. test_finalize_job_requeue_then_fail uses run_id=1/2) + therefore read whatever a real prod run happened to log — and a real 2.log that + contains "429" silently flips an expected 'permanent' classification to + 'transient', requeueing instead of failing. That is ambient prod pollution, not + a code fault. + + Redirecting runs_dir to an empty tmp dir makes _run_log_path() resolve to a + non-existent file -> classify_log_file() returns the documented 'permanent' + default, restoring deterministic, environment-independent behaviour for the + whole suite. settings is a process-wide singleton shared by launcher + (``launcher.settings is config.settings``), so patching the source covers it. + """ + from src import config as _cfg + monkeypatch.setattr(_cfg.settings, "runs_dir", str(tmp_path), raising=False) + yield + + @pytest.fixture(autouse=True) def _disable_merge_verify(monkeypatch): """ORCH-071: disable the merge-verify under-gate by default in ALL tests. From 9f62e05d01d7314531d239467021bfde6f6b5697 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 09:28:03 +0300 Subject: [PATCH 08/10] reviewer(ET): auto-commit from reviewer run_id=570 --- docs/work-items/ORCH-100/12-review.md | 155 +++++++++++++++----------- 1 file changed, 88 insertions(+), 67 deletions(-) diff --git a/docs/work-items/ORCH-100/12-review.md b/docs/work-items/ORCH-100/12-review.md index 2b5b9e7..3753e88 100644 --- a/docs/work-items/ORCH-100/12-review.md +++ b/docs/work-items/ORCH-100/12-review.md @@ -8,27 +8,36 @@ created_at: 2026-06-10 model_used: claude-opus-4-8 type: review work_item_id: ORCH-100 -version: 1 +version: 2 --- -# Review ORCH-100 — FND/F1b: sidecar-watchdog +# Review ORCH-100 — FND/F1b: sidecar-watchdog (re-review) ## Summary Аддитивная реализация sidecar-наблюдателя в отдельном контейнере `orchestrator-watchdog` -(папка `watchdog/`, тонкий Python-3.12-stdlib-only демон). Проверено по 4 осям. Реализация -**точно** соответствует ТЗ (FR-1…FR-11) и ADR-001 (D1…D9): отдельный контейнер, толерантный -парсинг `/metrics`, debounce `orch_down`, read-only `docker.sock` (GET-only по построению), -обобщённая чистая `decide`, независимый Telegram-канал, структурный анти-дубль диск-алерта (D6), -трёхуровневый never-raise, kill-switch. +(папка `watchdog/`, тонкий Python-3.12-stdlib-only демон). Это **повторное ревью** после цикла +`testing → development`: предыдущий прогон тестера дал `result: FAIL` из-за единственного красного +теста `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail`; развработчик закрыл +причину тест-only фиксом (коммит `2040de3` — autouse-фикстура `_isolate_runs_dir` в +`tests/conftest.py`, **без правок `src/**`**). -**Корневой инвариант соблюдён:** PR не трогает `src/**` ни одной строкой (подтверждено -`git diff --stat -- 'src/**'` → пусто) ⇒ `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / -machine-verdict ключи / схема БД орка — байт-в-байт прежние. Документация обновлена -исчерпывающе. 66 тестов `tests/watchdog/` зелёные. +Проверено по 4 осям. Реализация **точно** соответствует ТЗ (FR-1…FR-11) и ADR-001 (D1…D9): +отдельный контейнер, толерантный парсинг `/metrics` (D9), debounce `orch_down` (FR-3, порог +`orch_down_ticks`), read-only `docker.sock` (`_get` хардкодит `GET` — read-only **по построению** + +mount `:ro`), обобщённая чистая `decide` (D4, 1:1 семантика `disk_watchdog`), независимый +Telegram-канал (свои токены, ноль импортов `src/**`), структурный анти-дубль диск-алерта (D6, +opt-in потолок), трёхуровневый never-raise (per-source/per-tick/per-send), kill-switch (idle-loop, +не exit). -**Вердикт: APPROVED.** P0/P1, относящихся к этому PR, нет. Ниже — P2-замечания (не блокируют) -и одна эскалация по pre-existing красному тесту вне области F1b. +**Корневой инвариант соблюдён:** PR не трогает `src/**` ни одной строкой за всю ветку, включая +fix-коммит (`git diff origin/main...HEAD --stat -- 'src/**'` → пусто) ⇒ `STAGE_TRANSITIONS` / +`QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД орка — байт-в-байт прежние. + +**Блокер тестирования снят.** Полный регресс `pytest tests/` теперь **зелёный (1617 passed)`**, +профильная сюита `tests/watchdog/` — **66/66 PASS**. Документация обновлена исчерпывающе. + +**Вердикт: APPROVED.** P0/P1 нет. Ниже — анализ снятого блокера и P2/P3-замечания (не блокируют). ## Findings @@ -39,79 +48,91 @@ machine-verdict ключи / схема БД орка — байт-в-байт - (нет) ### P2 — Should fix -- [ ] **Заявление «полный регресс `tests/ -q` зелёный» неточно (CHANGELOG / AC-7).** Полный прогон - `pytest tests/` даёт **1 падение**: `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail`. - **Важно:** этот тест **байт-в-байт идентичен `origin/main`** (`git diff origin/main...HEAD -- - tests/test_queue.py` → пусто), а PR не меняет `src/**` — падение **pre-existing на main**, НЕ - вызвано ORCH-100 и **структурно неустранимо в рамках F1b** (ТЗ §2 прямо запрещает правки `src/**`). - Поэтому это **не основание для REQUEST_CHANGES** этого PR. Действие: скорректировать формулировку - «полный tests/ зелёный» → «`tests/watchdog/` зелёные (66); один pre-existing красный тест на main - вне области F1b» и завести отдельную баг-задачу (см. Escalation). Источник: AC-7 - (`03-acceptance-criteria.md`), CHANGELOG-запись ORCH-100. -- [ ] **FR-4: host inode/CPU не реализованы как сигналы.** ТЗ FR-4 упоминает «диск (% и, где - доступно, inode), память, CPU»; ADR-001 D5 (реестр сигналов) сузил host-метрики до `host_mem` + - opt-in `host_disk_crit`, и этот реестр реализован полностью. Host-CPU (loadavg) и inode в сигналы - не превращены — `watchdog/collectors/host.py` их не собирает (host-CPU/«завис» покрыт через - `agent_hung` из `/metrics`). Это документированное сужение на стадии архитектуры (FR-4 - формулирует inode как «где доступно»), не нарушение. Замечание: docstring ADR D3 для `host.py` - упоминает «CPU (loadavg)», которого в реализации нет — расхождение комментария и кода; либо - реализовать host-CPU/inode-сигнал, либо снять упоминание из docstring D3. Источник: `02-trz.md` - FR-4, `ADR-001` D3/D5. +- [ ] **ADR-001 D3: docstring-блок структуры `host.py` упоминает «CPU (loadavg)», которого в + реализации нет.** ADR D3 (строка с `host.py # ... / CPU (loadavg)`) перечисляет CPU/inode среди + host-метрик, но реестр сигналов D5 сознательно сузил host до `host_mem` + opt-in `host_disk_crit`, + а host-CPU/«завис» покрыт через `agent_hung` из `/metrics`. Сам `watchdog/collectors/host.py` + внутренне консистентен (его docstring явно пишет «CPU ... computed from the /metrics envelope, not + here»), inode FR-4 оговорён как «где доступно» — это документированное сужение на стадии + архитектуры, **не нарушение ТЗ**. Замечание косметическое: привести строку D3 в соответствие с D5 + (снять «CPU (loadavg)»/inode из блока структуры). Источник: `ADR-001` D3/D5, `02-trz.md` FR-4. ### P3 — Nice-to-have -- [ ] **CLAUDE.md не обновлён.** Паспорт проекта не получил запись о F1b. Прецедент: парная задача - F1a (ORCH-099) также отсутствует в CLAUDE.md (grep → 0) — это семейство наблюдаемости в паспорте - не трекается, а золотой архитектурный док (`docs/architecture/README.md`) покрывает F1b - исчерпывающе (компонентная строка + полная секция §«Sidecar-watchdog F1b»). Опционально для - единообразия с другими операционными демонами (`disk_watchdog`/`reaper`) — добавить краткую - TL;DR-строку. -- [ ] **`watchdog/collectors/containers.py::DockerSockReader.list_containers` не вызывается** в - `core.tick` (используется только `inspect(name)` по списку `cfg.containers`). Публичный read-метод - оставлен для полноты API/тестов — безвреден; при желании отметить как explicit-API или удалить. +- [ ] **CLAUDE.md не обновлён.** Паспорт проекта не получил TL;DR-запись о F1b. Прецедент: парная + задача F1a (ORCH-099) также отсутствует в CLAUDE.md (`grep` → 0) — семейство наблюдаемости в + паспорте не трекается, а золотой архитектурный док (`docs/architecture/README.md`) покрывает F1b + исчерпывающе. Опционально для единообразия с операционными демонами (`disk_watchdog`/`reaper`). +- [ ] **`DockerSockReader.list_containers` не вызывается** в `core.tick` (используется только + `inspect(name)` по `cfg.containers`). Публичный read-метод оставлен для полноты API/тестов + (`test_docker_readonly.py`) — безвреден; при желании пометить как explicit-API. + +## Анализ снятого блокера (testing FAIL → development fix) + +- **Причина прежнего FAIL:** `test_finalize_job_requeue_then_fail` (run_id=1/2) читал хвост + `/.log`. Дефолтный `runs_dir` указывал на прод-каталог + `/app/data/runs`, где на self-hosting-хосте лежат реальные накопленные `*.log`; реальный `2.log` + с токеном «429» переключал классификацию `permanent → transient` (requeue вместо `failed`). Это + **ambient prod-pollution окружения, не дефект кода** — сам тест байт-в-байт идентичен + `origin/main`, а `src/**` ORCH-100 не трогает. +- **Фикс (коммит `2040de3`):** autouse-фикстура `_isolate_runs_dir` редиректит `settings.runs_dir` + на per-test `tmp_path` ⇒ `_run_log_path()` резолвится в несуществующий файл ⇒ + `classify_log_file()` возвращает документированный дефолт `permanent` ⇒ детерминированный, + не зависящий от окружения результат для всей сюиты. Зеркалит существующие autouse-фикстуры + `_no_telegram`/`_disable_merge_verify`/`_reset_webhook_secrets`. +- **Это НЕ «подгонка теста под код»:** тело теста не изменено; добавлена только изоляция окружения + (test-infra). Фикс улучшает гигиену всей сюиты и устраняет скрытую env-зависимость. Прежний + диагноз тестера («реальное красное, ловящее расхождение requeue→finalize в launcher») оказался + ошибочным — корень был в загрязнении прод-логами; артефакт тестера (`13-test-report.md`) не правлю + (чужая стадия), фиксирую факт здесь. +- **Верификация (независимо):** `git diff origin/main...HEAD --stat -- 'src/**'` → пусто (включая + fix-коммит); изолированный прогон `test_finalize_job_requeue_then_fail` → **1 passed**; полный + `pytest tests/` → **1617 passed**; `tests/watchdog/` → **66 passed**. +- **Багфикс-трек (ORCH-019 BR-4):** задача — `feat`/FND (не `Bug`) ⇒ требование + регресс-теста-фиксатора не применяется. Фикс окружения, тем не менее, детерминирует поведение + всей сюиты. ## Документация **Обновлена исчерпывающе — golden source синхронизирован с кодом:** - ✅ `docs/architecture/README.md` — новая компонентная строка (Sidecar-watchdog F1b) + полная - секция «## Sidecar-watchdog F1b (ORCH-100 — design)» + перекрёстная ссылка в секции F1a. + секция дизайна F1b + перекрёстная ссылка из секции F1a. - ✅ `CHANGELOG.md` — детальная запись F1b (стек D1 / топология D2 / decide D4 / реестр сигналов D5 / - анти-дубль D6 / транспорт D7 / never-raise D8). + анти-дубль D6 / транспорт D7 / never-raise D8) **+** отдельная строка fix-коммита `2040de3` + (`_isolate_runs_dir`). - ✅ `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md` + сквозной `docs/architecture/adr/adr-0033-sidecar-watchdog.md` (оба с корректным frontmatter). - ✅ `docs/work-items/ORCH-100/07-infra-requirements.md` — разовое инфра-предусловие (сервис в compose, bot/chat watchdog, `.env.watchdog`, первый запуск). -- ✅ `.env.example` — канон всех `WATCHDOG_*` ключей, **без реальных секретов** (токены пустые). -- ⚠️ **CLAUDE.md** — не обновлён (P3, см. выше; прецедент F1a — допустимо). +- ✅ `.env.example` — канон всех `WATCHDOG_*` ключей, **без реальных секретов** (`TG_BOT_TOKEN`/ + `TG_CHAT_ID` пустые). +- ⚠️ **CLAUDE.md** — не обновлён (P3, прецедент F1a — допустимо). - ✅ **README «Известные ограничения» (ось ORCH-079):** F1b — новая способность (внешний - наблюдатель), **ни один** из 3 открытых пунктов витрины не закрывается этим PR ⇒ обновления - обзорной витрины не требуется (ось удовлетворена). + наблюдатель); **ни один** из 3 открытых пунктов витрины (Telegram-48h / intra-repo task-deps / + пакетный автоном Этап 1) не закрывается этим PR ⇒ обновления обзорной витрины не требуется. -**Поскольку `src/**` НЕ изменён, P0 «src изменён, документация не обновлена» не активируется**; -при этом документация всё равно обновлена сверх минимума. +**`src/**` НЕ изменён ⇒ P0 «src изменён, документация не обновлена» не активируется**; документация +при этом обновлена сверх минимума. ## Проверки инвариантов (явно) -- `git diff origin/main...HEAD --stat -- 'src/**'` → **пусто** (src нетронут; STAGE_TRANSITIONS / - QG_CHECKS / check_* / схема БД — байт-в-байт). +- `git diff origin/main...HEAD --stat -- 'src/**'` → **пусто** за всю ветку, включая fix-коммит + (STAGE_TRANSITIONS / QG_CHECKS / check_* / схема БД — байт-в-байт). - `docker.sock` смонтирован `:ro` (compose) И код GET-only по построению (`_get` хардкодит `GET`, - нет ни одного мутирующего метода) — двойная гарантия read-only (AC-6). -- Нет импорта `src/**` из `watchdog/**` (C-1 / BR-8) — независимый Telegram-транспорт со своими - токенами; падение орка не утянет алерт-канал. -- never-raise: per-source (коллекторы), per-tick (`__main__` + `core._dispatch`), per-send - (`notify`) — все три уровня присутствуют. -- kill-switch `WATCHDOG_ENABLED=false` → idle-loop (НЕ exit) — restart-policy не крутит петлю. + ни одного мутирующего метода/`POST`/start/stop/restart/exec) — двойная гарантия read-only (AC-6). +- Нет импорта `src/**` из `watchdog/**` (`grep` → пусто; C-1 / BR-8) — независимый Telegram-транспорт + со своими токенами; падение орка не утянет алерт-канал. +- never-raise: per-source (коллекторы `_collect_*`), per-tick (`__main__.run` + `core._dispatch`), + per-send (`notify`/`_send`) — все три уровня присутствуют (TC-06). +- kill-switch `WATCHDOG_ENABLED=false` → idle-loop (НЕ exit) — restart-policy не крутит петлю (TC-07). - `mem_limit: 128m` + `mem_reservation: 32m`; stdlib-only (нет requirements/pip-дерева) — тонкость - C-3 соблюдена. -- Багфикс-трек (ORCH-019 BR-4): задача — `feat`/FND, не `Bug` ⇒ требование регресс-теста-фиксатора - не применяется. + C-3 соблюдена; compose-сервис изолирован (деплой watchdog НЕ пересобирает/рестартит `orchestrator`). +- Анти-дубль диска (D6/AC-5): `host_disk_crit` opt-in (`disk_crit_enabled=False` по умолчанию) на + более высоком потолке (97%) — структурно один владелец 85%-события (`disk_watchdog`/ORCH-063). ## Escalation -- **Pre-existing красный тест на `main`:** `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail` - падает стабильно (3/3 изолированных прогона) и идентичен `origin/main`. Он **не вызван** ORCH-100 - и **неустраним в области F1b** (правки `src/**` запрещены ТЗ §2). Однако он **завалит** downstream - merge-gate re-test (ORCH-043 гоняет `pytest tests/` на догнанной ветке) и нарушает AC-7 «полный - tests/ зелёный» на уровне репозитория. **Рекомендация:** Owner/планировщику завести отдельную - баг-задачу (метка `Bug`, маршрут ORCH-019) на починку `test_finalize_job_requeue_then_fail` в - `src/**`; без неё прод-выкат ORCH-100 упрётся в merge-gate по причине, не относящейся к F1b. Это - эскалация, а не finding против данного PR. +- Нет открытых эскалаций. Прежняя эскалация ревью v1 / тест-репорта (pre-existing красный тест) — + **закрыта** fix-коммитом `2040de3` (test-only изоляция окружения, `src/**` не тронут). Полный + регресс `pytest tests/` зелёный (1617 passed) ⇒ downstream merge-gate re-test (ORCH-043) по этой + причине более не упрётся. Отдельная баг-задача на `test_finalize_job_requeue_then_fail` **не + требуется**: корнем было загрязнение прод-логами, а не дефект `src/**`. From 0ef1cf669866c787dd0c4773d9ae5501b041aed0 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 09:31:09 +0300 Subject: [PATCH 09/10] tester(ET): auto-commit from tester run_id=571 --- docs/work-items/ORCH-100/13-test-report.md | 138 +++++++++------------ 1 file changed, 61 insertions(+), 77 deletions(-) diff --git a/docs/work-items/ORCH-100/13-test-report.md b/docs/work-items/ORCH-100/13-test-report.md index 00e7396..2376144 100644 --- a/docs/work-items/ORCH-100/13-test-report.md +++ b/docs/work-items/ORCH-100/13-test-report.md @@ -1,123 +1,107 @@ --- -result: FAIL # PASS | FAIL — машинный вердикт, UPPERCASE +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE work_item: ORCH-100 stage: testing author_agent: tester -status: fail +status: pass created_at: 2026-06-10 model_used: claude-opus-4-8 type: test-report work_item_id: ORCH-100 --- -# Test Report — ORCH-100 — FND/F1b: sidecar-watchdog +# Test Report — ORCH-100 — FND/F1b: sidecar-watchdog (re-test) -> Машинный вердикт читается ТОЛЬКО из frontmatter (`result:`, UPPERCASE). Негативный токен -> (`FAIL`) — авторитетен. +> Повторный прогон после цикла `testing → development → review`. Прежний блокер прошлого прогона +> (`tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail`) снят fix-коммитом +> `2040de3` (test-only autouse-фикстура `_isolate_runs_dir` в `tests/conftest.py`, изолирующая +> `settings.runs_dir` от ambient prod-log pollution; `src/**` не тронут). Полный регресс снова зелёный. ## Окружение - Python: 3.12.13 - pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8) - Дата: 2026-06-10 - Worktree: `/repos/_wt/orchestrator/feature_ORCH-100-fnd-f1b-sidecar-watchdog` - (ветка `feature/ORCH-100-fnd-f1b-sidecar-watchdog`) — тесты прогнаны из рабочего дерева - именно этой задачи, НЕ из общего `/repos/orchestrator`. + (ветка `feature/ORCH-100-fnd-f1b-sidecar-watchdog`, HEAD `a153c8e`, fix `2040de3` в истории) — + тесты прогнаны из рабочего дерева именно этой задачи, НЕ из общего `/repos/orchestrator`. ## Smoke API (read-only) | Эндпоинт | Результат | |----------|-----------| | `GET /health` | OK — `{"status":"ok","service":"orchestrator"}` | -| `GET /status` | OK — задача `ORCH-100` (id 85) в стадии `testing`, активный набор задач отдан | -| `GET /queue` | OK — блоки `serial_gate` **И** `auto_labels` присутствуют в полезной нагрузке (анти-регресс смока ORCH-088 соблюдён) | -| `GET /metrics` (ORCH-099, потребляется F1b) | OK — конверт `{schema_version=1, generated_at, clk_tck, stages, queue, agents, cost}` цел | +| `GET /status` | OK — валидный JSON, активный набор задач отдан | +| `GET /queue` | OK — блоки `serial_gate` (ORCH-088) **И** `auto_labels` (ORCH-089) присутствуют в полезной нагрузке (анти-регресс смока соблюдён) | Smoke зелёный, прод-контейнер не трогался (только чтение). ## Результаты ### Профильная сюита F1b — `tests/watchdog/` -**66 passed** (0 failed). Полностью зелёная — это собственно поставка F1b (отдельный -sidecar-демон: решающая функция, парсинг `/metrics`, детект orchestrator-down, never-raise, -read-only docker, изолированный транспорт, kill-switch, compose-инвариант, анти-дубль диск-алерта). +**66 passed** (0 failed) — собственно поставка F1b: решающая функция, парсинг `/metrics`, детект +orchestrator-down, never-raise, read-only docker, изолированный транспорт, kill-switch, +compose-инвариант, анти-дубль диск-алерта. ### Полный регресс орка — `pytest tests/` -**1 failed, 1616 passed** (`tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail`). - -``` -tests/test_queue.py:189: in test_finalize_job_requeue_then_fail - assert get_job(jid)["status"] == "failed" # 2 >= 2 -> failed -E AssertionError: assert 'queued' == 'failed' -E - failed -E + queued -``` - -#### Классификация падения (верифицировано независимо) -- `git diff origin/main...HEAD --stat -- 'src/**'` → **пусто** ⇒ ORCH-100 не меняет `src/**` - ни одной строкой. -- `git diff origin/main...HEAD -- tests/test_queue.py` → **пусто** ⇒ файл теста байт-в-байт - идентичен `origin/main`. -- Падение **детерминированно** и воспроизводится в изоляции (2/2 прогона), не артефакт - порядка тестов. -- **Вывод:** падение — **pre-existing на `main`**, НЕ вызвано F1b и **структурно неустранимо - в рамках ORCH-100** (ТЗ §2 прямо запрещает правки `src/**`). Это реальное, обоснованное - красное (тест ловит расхождение логики requeue→finalize с transient-backoff в - `launcher`), а не флап окружения. +**1617 passed** (0 failed, 1 warning — pre-existing Pydantic V2 deprecation в `src/config.py:8`, +не относится к ORCH-100). `src/**` не изменён за всю ветку (`git diff origin/main...HEAD -- 'src/**'` +→ пусто) ⇒ контракт `/metrics` (ORCH-099), `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД — целы. ## Сопоставление с тест-планом (`04-test-plan.yaml`) -| TC ID | Описание | Тест-функция / модуль | Результат | -|-------|----------|------------------------|-----------| -| TC-01 | not-alerting & ≥threshold → ALERT | `test_decision.py::test_tc01_not_alerting_active_alerts` (+ inactive→none) | PASS | -| TC-02 | alerting & cooldown НЕ истёк → NONE (throttle) | `test_decision.py::test_tc02_alerting_active_in_cooldown_is_none` | PASS | -| TC-03 | alerting & cooldown истёк → REALERT | `test_decision.py::test_tc03_alerting_active_cooldown_elapsed_realerts` | PASS | -| TC-04 | alerting & вернулось ниже порога → RECOVERY | `test_decision.py::test_tc04_alerting_recovers_when_inactive` | PASS | -| TC-05 | детект orchestrator-down (timeout/refused/5xx/нечит. тело) → ALERT + debounce | `test_orch_down.py` (7 тестов) | PASS | -| TC-06 | never-raise per-source/per-tick/per-send | `test_never_raise.py` (3 теста) | PASS | -| TC-07 | kill-switch инертен; пороги/интервалы/таймауты из env | `test_config_killswitch.py` (4 теста) | PASS | -| TC-08 | интеграция: полный тик при down орке (1 алерт + throttle + recovery; всё ломается — тик не падает) | `test_tick_orch_down_integration.py` (2 теста) | PASS | -| TC-09 | self-hosting safety: docker GET-only, без start/stop/restart/exec | `test_docker_readonly.py` (5 тестов) | PASS | -| TC-10 | независимый транспорт: свои токен/chat, без импорта `src/notifications.py`/`src` | `test_notify_isolation.py` (6 тестов) | PASS | -| TC-11 | толерантность `/metrics`: неизвестное поле игнор, опц. отсутствие ок, рост schema_version → warning | `test_metrics_parse.py` (10 тестов) | PASS | -| TC-12 | compose-инвариант: отдельный сервис `orchestrator-watchdog`, build `watchdog/`, restart, mem_limit, docker.sock `:ro` | `test_compose_service.py` (7 тестов) | PASS | -| TC-13 | анти-дубль диск-алерта (согласовано с ORCH-063) | `test_disk_alert_dedup.py` (3 теста) | PASS | -| **TC-14** | **регресс орка: полный `pytest tests/` зелёный; `src/**` не изменён; `/metrics`-контракт цел** | `tests/` | **FAIL** | +| TC ID | Описание | Тест-функция / модуль | Покрытый AC | Результат | +|-------|----------|------------------------|-------------|-----------| +| TC-01 | not-alerting & ≥threshold → ALERT (один на пересечение) | `test_decision.py::test_tc01_*` (active + inactive→none) | AC-2 | PASS | +| TC-02 | alerting & cooldown НЕ истёк → NONE (throttle) | `test_decision.py::test_tc02_alerting_active_in_cooldown_is_none` | AC-2 | PASS | +| TC-03 | alerting & cooldown истёк → REALERT | `test_decision.py::test_tc03_*` (elapsed + no_last_alert) | AC-2 | PASS | +| TC-04 | alerting & вернулось ниже порога → RECOVERY | `test_decision.py::test_tc04_alerting_recovers_when_inactive` | AC-2 | PASS | +| TC-05 | детект orchestrator-down (timeout/refused/5xx/нечит. тело) → ALERT + debounce | `test_orch_down.py` (7 тестов) | AC-2/AC-3 | PASS | +| TC-06 | never-raise per-source/per-tick/per-send | `test_never_raise.py` (3 теста) | AC-3 | PASS | +| TC-07 | kill-switch инертен; пороги/интервалы/таймауты из env (не хардкод) | `test_config_killswitch.py` (4 теста) | AC-4 | PASS | +| TC-08 | интеграция: полный тик при down орке (1 алерт + throttle + recovery; всё ломается — тик не падает) | `test_tick_orch_down_integration.py` (2 теста) | AC-2/AC-3 | PASS | +| TC-09 | self-hosting safety: docker GET-only, без start/stop/restart/exec | `test_docker_readonly.py` (5 тестов) | AC-6 | PASS | +| TC-10 | независимый транспорт: свои токен/chat, без импорта `src/notifications.py`/`src` | `test_notify_isolation.py` (6 тестов) | AC-2/AC-6 | PASS | +| TC-11 | толерантность `/metrics`: неизвестное поле игнор, опц. отсутствие ок, рост schema_version → warning | `test_metrics_parse.py` (10 тестов) | AC-1 | PASS | +| TC-12 | compose-инвариант: отдельный сервис `orchestrator-watchdog`, build `watchdog/`, restart, mem_limit, docker.sock `:ro` | `test_compose_service.py` (7 тестов) | AC-1/AC-4/AC-6 | PASS | +| TC-13 | анти-дубль диск-алерта (согласовано с ORCH-063) | `test_disk_alert_dedup.py` (3 теста) | AC-5 | PASS | +| TC-14 | регресс орка: полный `pytest tests/` зелёный; `src/**` не изменён; `/metrics`-контракт цел | `tests/` (1617 passed) | AC-7 | PASS | -**TC-14 детально (смешанный исход → FAIL по букве):** -- ✅ `src/**` не изменён (диск-проверка пуста). -- ✅ `/metrics`-контракт (ORCH-099) не сломан (конверт цел, смок зелёный). -- ❌ полный `pytest tests/` **НЕ зелёный** — 1 pre-existing красное (см. выше). -- Поскольку TC-14 ожидает **PASS** по условию «полный `pytest tests/` зелёный», а суммарный - прогон репозитория красный — **TC-14 = FAIL**. Это единственный FAIL; все 13 профильных - TC (TC-01…TC-13) зелёные. +**Покрытие:** все 14 TC из `04-test-plan.yaml` выполнены, сопоставлены с AC-1…AC-7 +(`03-acceptance-criteria.md`) и зелёные. ## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) | AC | Покрытие | Результат | |----|----------|-----------| -| AC-1 — sidecar отдельным контейнером собирает 4 источника | TC-12 + коллекторы host/deps/docker/metrics | PASS | -| AC-2 — пороговый алерт: один на пересечение + throttle + recovery + орк-down | TC-01…TC-05 | PASS | +| AC-1 — sidecar отдельным контейнером собирает 4 источника | TC-11/12 + коллекторы host/deps/docker/metrics | PASS | +| AC-2 — пороговый алерт: один на пересечение + throttle + recovery + орк-down | TC-01…TC-05/08/10 | PASS | | AC-3 — изоляция: падение орка не роняет sidecar | TC-05/06/08 | PASS | | AC-4 — тонкость, kill-switch, конфиг-пороги | TC-07/12 | PASS | | AC-5 — анти-дубль диск-алерта (ORCH-063) | TC-13 | PASS | -| AC-6 — self-hosting safety (только чтение/алерт) | TC-09/10 | PASS | -| AC-7 — инфра-доки + `pytest` зелёный + docs/CHANGELOG | `07-infra-requirements.md` ✅, CHANGELOG ✅, доки ✅; **но полный `pytest tests/` НЕ зелёный** | **FAIL** (под-условие «полный `tests/` зелёный» нарушено) | +| AC-6 — self-hosting safety (только чтение/алерт) | TC-09/10/12 | PASS | +| AC-7 — инфра-доки + `pytest` зелёный + docs/CHANGELOG | `07-infra-requirements.md` ✅, CHANGELOG ✅, доки ✅, полный `pytest tests/` 1617 passed ✅ | PASS | + +## Вывод pytest + +### Полный регресс (`pytest tests/ -q`) +``` +........................................................................ [100%] +1617 passed, 1 warning in 65.33s (0:01:05) +``` + +### Профильная сюита (`pytest tests/watchdog/ -v`) +``` +collected 66 items +... (все 66 PASSED) ... +======================== 66 passed, 1 warning in 0.57s ========================= +``` ## Эскалация -Падение `tests/test_queue.py::TestRetry::test_finalize_job_requeue_then_fail` — **обоснованный -pre-existing FAIL на `main`**, не относящийся к F1b и **неустранимый в области ORCH-100** (правки -`src/**` запрещены ТЗ §2). Откат на `development` в рамках ORCH-100 его **не починит** (разработчик -F1b не вправе трогать `src/**`). **Рекомендация (зеркало эскалации reviewer'а в `12-review.md`):** -Owner/планировщику завести **отдельную баг-задачу** (метка `Bug`, маршрут ORCH-019) на починку -`test_finalize_job_requeue_then_fail` в `src/launcher`-логике requeue→finalize. Без неё прод-выкат -ORCH-100 всё равно упрётся в downstream merge-gate re-test (ORCH-043 гоняет `pytest tests/` на -догнанной ветке) по причине, не относящейся к F1b. +Нет открытых эскалаций. Прежний pre-existing красный тест (`test_finalize_job_requeue_then_fail`) +снят fix-коммитом `2040de3` (изоляция `settings.runs_dir`, test-only, `src/**` не тронут) и +независимо подтверждён зелёным в этом прогоне. Отдельная баг-задача более не требуется. ## Итог -**FAIL.** - -- Поставка F1b сама по себе полностью зелёная: `tests/watchdog/` 66/66 PASS, smoke (`/health`, - `/status`, `/queue`, `/metrics`) зелёный, все профильные TC-01…TC-13 + AC-1…AC-6 — PASS. -- Однако **полный регресс репозитория `pytest tests/` красный** (1 pre-existing fail, 1616 pass) - → TC-14 и под-условие AC-7 «полный `tests/` зелёный» нарушены → машинный вердикт `result: FAIL`. -- Тестер не помечает красный регресс зелёным (`❌ не подгонять тесты под код`). Падение — - exogenous к F1b и требует отдельной баг-задачи (эскалация выше), а не доработки ORCH-100. +**PASS** — полный регресс `pytest tests/` зелёный (1617 passed), профильная сюита sidecar-watchdog +66/66 PASS, smoke API (`/health`/`/status`/`/queue` с блоками `serial_gate` + `auto_labels`) read-only +прошёл без регресса. Каждый TC (TC-01…TC-14) выполнен и сопоставлен с AC-1…AC-7. Блокеров нет. +Задача готова к переходу на `deploy-staging`. From e7dad0f64446221f08e174aa9c60e3cc311e6454 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Wed, 10 Jun 2026 09:57:11 +0300 Subject: [PATCH 10/10] deploy(ORCH-036): finalize SUCCESS for ORCH-100 --- docs/work-items/ORCH-100/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-100/14-deploy-log.md diff --git a/docs/work-items/ORCH-100/14-deploy-log.md b/docs/work-items/ORCH-100/14-deploy-log.md new file mode 100644 index 0000000..ebc9d6d --- /dev/null +++ b/docs/work-items/ORCH-100/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-100 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.