--- work_item: ORCH-063 stage: analysis author_agent: analyst status: ready-for-review created_at: 2026-06-09 model_used: claude-opus-4-8 --- # 02 — ТЗ (TRZ): ORCH-063 — INFRA: мониторинг диска mva154 + алерт при >85% Work Item: **ORCH-063** · Repo: **orchestrator** · Стадия: analysis > ТЗ описывает **что** и **где** должно измениться (модули/контракты/артефакты), выведенное из BRD и > фактического кода. **Как** (точная структура демона, способ замера, хранение состояния анти-спама, > точки врезки) — решает архитектор в `06-adr/`. ТЗ фиксирует требования и границы. --- ## 1. Сводка изменения Ввести **disk-watchdog** — фоновый daemon-поток (по образцу `reconciler`/`job_reaper`), который периодически (heartbeat) измеряет заполнение **хост-файловой системы** через смонтированные в контейнер bind-пути и при пересечении настраиваемого порога (дефолт **85%**) шлёт **Telegram-алерт** оператору. Анти-спам (алерт на пересечение + ограниченное повторение + recovery при возврате ниже порога), наблюдаемость в `GET /queue`, kill-switch, never-raise. **Машина стадий, реестр QG и схема БД-контрактов не меняются; новой миграции не требуется.** --- ## 2. Задействованные модули / пути | Путь | Действие | |------|----------| | `src/disk_watchdog.py` *(новый leaf-модуль; имя — на усмотрение архитектора)* | **создать** — чистая логика замера + решение об алерте (pure, тестируемо) + daemon-обёртка (`threading.Thread(daemon=True)` + `threading.Event`, `start`/`stop`/`status`), never-raise. Образец: `src/reconciler.py`, `src/job_reaper.py`. | | `src/config.py` | **изменить** — добавить флаги фичи (см. §8). | | `src/main.py` | **изменить** — `start()`/`stop()` watchdog в `lifespan` (после `reaper.start()` / в reverse-порядке на shutdown); добавить read-only блок `disk_monitor` в `GET /queue`. | | `src/notifications.py` | **изменить (опц.)** — переиспользовать `send_telegram(text)` (notifying) напрямую из watchdog **или** добавить тонкий helper `notify_disk_alert(...)`/`notify_disk_recovery(...)` (never-raise). Выбор — архитектор. | | `.env.example` | **изменить** — задокументировать новые `ORCH_DISK_*` переменные (дескрипторы, без значений-секретов). | > Чистую логику (замер по путям, дедуп по устройству, решение «алертить / повторить / recovery» как > функция от текущего %, порога и предыдущего состояния) держать в **leaf-модуле**, never-raise, по > образцу `src/task_deps.py` / `src/post_deploy.py` — для юнит-тестируемости без фонового потока. --- ## 3. Функциональные требования ### FR-1 — Heartbeat-демон (BR-1, BR-8) - Фоновый daemon-поток измеряет заполнение диска каждые `disk_monitor_interval_s` секунд. - Стартует/останавливается в `main.lifespan` (паттерн `reconciler.start()`/`reaper.start()` и reverse на shutdown). Период — `threading.Event().wait(interval)` (чистый stop, как `reconciler._run`). - Контракт демона: `start()`, `stop(timeout)`, `status() -> dict` (для `/queue`). ### FR-2 — Замер заполнения хост-ФС (BR-1, NFR-3) - Для каждого пути из `disk_monitor_paths` измерить заполнение (`used/total`, %), свободно (байты/%). - **Источник — смонтированные хост-пути**, а не overlay `/` контейнера (NFR-3): дефолтный набор путей должен покрывать раздел(ы), на которых растут рабочие данные оркестратора — `/repos` (host `/home/slin/repos`) и `/app/data` (host `./data`). Способ замера — предпочтительно stdlib `shutil.disk_usage(path)` (без субпроцесса `df` на каждом тике, NFR-2); финальный выбор — архитектор. - При совпадении физического устройства у нескольких путей — желательно не дублировать алерт (дедуп по устройству `st_dev`/mount); требование «желательно», не блокирующее. - Недоступный/несуществующий путь → пропуск этого пути с лог-warning, без падения тика. ### FR-3 — Алерт при превышении порога (BR-2) - Если заполнение пути **≥ `disk_monitor_threshold_pct`** (дефолт `85`) — сформировать и отправить Telegram-алерт через `send_telegram` (notifying, **не** silent — это alert, как `notify_error`). - **Содержимое алерта (действенное):** идентификатор хоста/пути (точка монтирования), занято %, свободно (ГБ и/или %), порог. Текст — на русском, по стилю существующих `notify_*`-алертов. ### FR-4 — Анти-спам, повтор и recovery (BR-3, BR-4) - Решение об отправке — функция от `(current_pct, threshold, previous_state, now)`: - **переход «ниже→на/выше порога»** → отправить алерт (первое пересечение); - **остаётся выше порога** → повторно слать **не чаще**, чем раз в `disk_monitor_realert_s` (cooldown), а не на каждом тике; - **переход «выше→ниже порога»** → сбросить состояние алерта и отправить однократное **recovery-сообщение** «диск ниже порога» (notifying). - Состояние анти-спама может быть **in-memory** (best-effort; после рестарта допустим повторный алерт, если всё ещё выше порога — безопасно, NFR-5). Время — через инъецируемый `now`-провайдер, чтобы решение было тестируемо без реального таймера. ### FR-5 — Конфигурируемость и kill-switch (BR-5, NFR-4) - Поведение управляется флагами `config.py` (см. §8). При `disk_monitor_enabled=False` watchdog **не запускается** (демон не стартует в `lifespan`) — нулевая регрессия. ### FR-6 — Наблюдаемость (BR-7) - `GET /queue` получает аддитивный read-only блок `disk_monitor` (по образцу блоков `reconcile`/ `reaper`/`serial_gate`): `enabled`, `threshold_pct`, `interval_s`, `paths` с последним замером (`used_pct`, `free_bytes`/`free_gb`), `alerting` (bool на путь/глобально), `last_alert_at`. never-raise: при ошибке — минимальный словарь с флагами. --- ## 4. Изменения API - **Новых обязательных endpoint'ов нет.** Снимок состояния отдаётся через существующий `GET /queue` (аддитивный блок `disk_monitor`, §3/FR-6); существующие ключи ответа не меняются. - Опционально (на усмотрение архитектора, **не обязательно**): отдельный `GET /disk` для on-demand замера. Если вводится — задокументировать в README. Рекомендация: ограничиться блоком в `/queue`. --- ## 5. Изменения схемы БД **Нет.** Состояние watchdog — best-effort, держится в памяти демона (NFR-5). Новых таблиц/колонок/ миграций не вводится. `STAGE_TRANSITIONS`/`QG_CHECKS`/`tasks`/`jobs`/`agent_runs` — без изменений. > Если архитектор решит сделать состояние last-alert durable (переживающим рестарт) — допустима > только **аддитивная, идемпотентная** миграция (`CREATE TABLE IF NOT EXISTS`), но это **не** > требование ТЗ (по умолчанию — in-memory). --- ## 6. Требования к новым/изменённым QG checks **Нет.** Watchdog — фоновый эксплуатационный демон, **не** Quality Gate стадии. Реестр `QG_CHECKS` и `check_*` не трогаются (аналогично `reconciler`/`job_reaper`, которые тоже не являются QG). --- ## 7. Совместимость / регресс - **Аддитивно:** новый leaf-модуль + точечные врезки в `main.lifespan` и `GET /queue` + флаги config. Существующий код не переписывается. - **Kill-switch** `disk_monitor_enabled` (дефолт `True`): `False` → демон не стартует, `/queue`-блок отдаёт `{"enabled": false}` — поведение приложения 1:1 как сейчас (NFR-4). - **never-raise:** изоляция фонового потока (паттерн `reconciler`/`reaper`); сбой замера/отправки/ тика не влияет на конвейер (BR-6/NFR-1). Демон бежит в общем self-hosting-инстансе — обязан быть безопасным для enduro-trails. - **Обратимость:** удаление эффекта = выключение флага; миграций БД нет, откат тривиален. - **Self-hosting:** watchdog только читает заполнение и шлёт уведомление — не трогает диск/контейнер, не рестартит прод (NFR-6). --- ## 8. Конфигурация (`src/config.py`) По образцу `reconcile_*` / `merge_gate_*`: | Поле (env) | Тип / дефолт | Назначение | |------------|--------------|------------| | `disk_monitor_enabled` (`ORCH_DISK_MONITOR_ENABLED`) | `bool = True` | kill-switch; `False` → демон не стартует (нулевая регрессия). | | `disk_monitor_interval_s` (`ORCH_DISK_MONITOR_INTERVAL_S`) | `int = 300` | период heartbeat-замера, сек (порядок минут, NFR-2). | | `disk_monitor_threshold_pct` (`ORCH_DISK_MONITOR_THRESHOLD_PCT`) | `int = 85` | порог заполнения для алерта (дефолт фиксирован Владельцем). | | `disk_monitor_realert_s` (`ORCH_DISK_MONITOR_REALERT_S`) | `int = 21600` | минимальный интервал между повторными алертами, пока выше порога (анти-спам; ~6 ч). | | `disk_monitor_paths` (`ORCH_DISK_MONITOR_PATHS`) | `str = "/repos,/app/data"` (CSV) | отслеживаемые пути (смонтированные хост-разделы, NFR-3); пусто → дефолтный набор. | Финальный набор/имена флагов и дефолты уточняет архитектор; диапазон/валидация значений (порог в 1..100, интервалы > 0) — defensive, невалидное → дефолт + лог-warning (паттерн `reconcile_grace_*`). --- ## 9. Артефакты pipeline (создать/обновить в ТОМ ЖЕ PR) Документация — golden source (CLAUDE.md §2). По итогам разработки обновить: - `docs/work-items/ORCH-063/06-adr/ADR-001-disk-watchdog.md` — решение (способ замера хост-ФС, набор путей/дедуп, хранение состояния анти-спама, точки врезки, дефолты порога/периода). - `docs/architecture/README.md` — новый компонент «Disk-watchdog (ORCH-063)» в списке компонентов + описание блока `disk_monitor` в `GET /queue`. - `docs/operations/INFRA.md` — раздел/строки про disk-watchdog: что мониторится, порог, как отключить (`ORCH_DISK_MONITOR_ENABLED`), что делать при алерте (ручная очистка — ссылка/руководство). - `.env.example` — новые `ORCH_DISK_*` дескрипторы. - `CHANGELOG.md` — запись `feat:`. - При новом endpoint `/disk` (если архитектор введёт) — обновить таблицу API в README. --- ## 10. Инварианты (не нарушать) - `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, схема существующих таблиц БД — **без изменений**. - never-raise на тик демона; сбой watchdog не блокирует и не роняет конвейер (NFR-1). - Замер — по **хост-разделам** (bind-mount-пути), не по overlay `/` контейнера (NFR-3). - Не рестартить/не ронять прод-контейнер; watchdog только читает и уведомляет (NFR-6, self-hosting). - При выключенном флаге — поведение 1:1 как сейчас; enduro-trails не затрагивается. --- ## 11. Открытые вопросы для архитектора (не блокируют анализ) - OQ-1: Способ замера — stdlib `shutil.disk_usage(path)` vs субпроцесс `df` (рекомендация — stdlib, NFR-2). - OQ-2: Дедуп путей по физическому устройству (`os.stat().st_dev`), чтобы единый host-раздел не алертил дважды. - OQ-3: Состояние анти-спама — in-memory (рекомендация) vs durable (доп. таблица); влияет на поведение после рестарта. - OQ-4: Нужен ли второй «критический» порог (напр. 95%) с усиленным/более частым алертом — кандидат, по умолчанию **нет** (один порог 85%). - OQ-5: Helper в `notifications.py` (`notify_disk_alert`) vs прямой вызов `send_telegram` из watchdog.