---
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/<name>/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-семантика).
</content>
</invoke>
