From 3294dec9bab373f91bfac1e7573e13526bc524f0 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 18:34:18 +0300 Subject: [PATCH] analyst(ET): auto-commit from analyst run_id=483 --- docs/work-items/ORCH-063/01-brd.md | 147 ++++++++++++++ docs/work-items/ORCH-063/02-trz.md | 186 ++++++++++++++++++ .../ORCH-063/03-acceptance-criteria.md | 132 +++++++++++++ docs/work-items/ORCH-063/04-test-plan.yaml | 92 +++++++++ 4 files changed, 557 insertions(+) create mode 100644 docs/work-items/ORCH-063/01-brd.md create mode 100644 docs/work-items/ORCH-063/02-trz.md create mode 100644 docs/work-items/ORCH-063/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-063/04-test-plan.yaml diff --git a/docs/work-items/ORCH-063/01-brd.md b/docs/work-items/ORCH-063/01-brd.md new file mode 100644 index 0000000..56ad6a8 --- /dev/null +++ b/docs/work-items/ORCH-063/01-brd.md @@ -0,0 +1,147 @@ +--- +work_item: ORCH-063 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-063 — INFRA: мониторинг диска mva154 + алерт при >85% + +Work Item: **ORCH-063** · Repo: **orchestrator** (self-hosting) · Стадия: analysis +Заказчик: Слава (Владелец/оператор) +Тип: INFRA · Приоритет: **P1** + +--- + +## 1. Бизнес-контекст и проблема + +### 1.1. Инцидент (установленный факт) +**07.06.2026** диск на хосте **mva154** (`slin@82.22.50.71`) незаметно дорос до **100%** и положил +**весь конвейер**: CI стал красным, очередь Gitea застряла. Сбой произошёл **тихо** — не было +ни одного предупреждающего сигнала до полного исчерпания диска. Разбор был ручным и пост-фактум. + +### 1.2. Корневая боль +У оркестратора **нет проактивного сигнала о заполнении диска**. Диск хоста заполняется накопительно +и предсказуемо (git-worktree в `/repos/_wt/...`, образы Docker, БД `./data/orchestrator.db`, логи), +но оператор узнаёт о проблеме только когда уже **поздно** — конвейер всех проектов (self-hosting: +`orchestrator` + `enduro-trails` из одного инстанса) уже встал. + +### 1.3. Self-hosting контекст (групповой риск) +Прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты, с общей БД и общей очередью +(`docs/operations/INFRA.md`). Исчерпание диска роняет конвейер **всех** проектов сразу. Ранний +сигнал (heartbeat-watchdog) — дешёвая страховка от дорогого группового простоя. + +### 1.4. Что нужно (формулировка Владельца) +**Heartbeat-watchdog:** периодически измерять заполнение диска (`df`); при превышении порога +**85%** — слать алерт Славе (Telegram). Сигнал должен прийти **заранее**, пока есть запас места +на ручную/будущую авто-очистку. + +--- + +## 2. Объём (scope) + +### 2.1. В объёме +- **Фоновый watchdog-демон** (по образцу `reconciler`/`job_reaper`, ORCH-053/065): периодически + семплит заполнение хост-ФС, на которой живут рабочие данные оркестратора (репозитории, БД, + Docker), и при пересечении порога шлёт Telegram-алерт оператору. +- **Конфигурируемый порог** (дефолт **85%**), период опроса, kill-switch. +- **Анти-спам:** алерт по факту пересечения порога + ограниченное по частоте повторение, пока + заполнение выше порога (а не на каждом тике); сообщение о возврате «ниже порога» (recovery). +- **Наблюдаемость** последнего замера/состояния алерта в `GET /queue` (read-only). +- **never-raise:** любой сбой watchdog не влияет на конвейер. + +### 2.2. Вне объёма (явно, не делать) +- **Авто-очистка / garbage collection диска** (прунинг старых worktree, образов, логов, vacuum БД) — + отдельная задача; ORCH-063 только **сигнализирует**, не **лечит**. +- Интеграция с внешними системами мониторинга (Prometheus/Grafana/Zabbix), метрики/экспортёры. +- Алерт-каналы кроме существующего Telegram (`send_telegram`). +- Мониторинг ресурсов кроме диска (CPU/RAM/inode — возможное расширение, не сейчас; inode — + кандидат на follow-up, см. §8 R-4). +- Мониторинг нескольких хостов / удалённый сбор (только локальный хост текущего инстанса). +- Изменение `STAGE_TRANSITIONS`, реестра `QG_CHECKS`, стадий конвейера, схемы БД-контрактов. + +--- + +## 3. Заинтересованные стороны +- **Владелец/оператор (Слава):** получает алерт, выполняет ручную очистку/реакцию; принимает + результат. +- **Self-hosting прод (`orchestrator`):** обслуживает enduro-trails из того же инстанса — watchdog + не должен мешать/ронять конвейер (изоляция через never-raise). +- **Все прод-проекты:** косвенные бенефициары — ранний сигнал предотвращает групповой простой. + +--- + +## 4. Бизнес-требования (BR) + +| ID | Требование | Связь | +|----|------------|-------| +| BR-1 | Оркестратор **периодически** (heartbeat) измеряет заполнение хост-файловой системы, на которой растут его рабочие данные (репозитории `/repos`, БД `/app/data`, Docker). | FR-1, AC-1 | +| BR-2 | При достижении/превышении **порога заполнения** (дефолт **85%**) оператор получает **Telegram-алерт** с действенными деталями: точка монтирования/путь, занято %, свободно (ГБ/%). | FR-2, FR-3, AC-2 | +| BR-3 | **Анти-спам:** алерт шлётся при **пересечении** порога (переход «ниже→на/выше»), а далее повторяется не чаще, чем раз в настраиваемый период (`re-alert`), пока заполнение остаётся выше порога — конвейер/чат не заваливается одинаковыми сообщениями на каждом тике. | FR-4, AC-3 | +| BR-4 | При возврате заполнения **ниже порога** состояние алерта сбрасывается и отправляется однократное сообщение восстановления «диск ниже порога» (recovery), чтобы оператор знал, что инцидент снят. | FR-4, AC-4 | +| BR-5 | Порог, период опроса, период повторного алерта и набор отслеживаемых путей **конфигурируемы**; есть **kill-switch** для полного отключения watchdog (нулевая регрессия). | FR-5, AC-5 | +| BR-6 | **never-raise:** любая ошибка измерения/отправки алерта/самого демона **не роняет** и не блокирует конвейер (фоновый поток, изолированный как `reconciler`/`reaper`). | NFR-1, AC-6 | +| BR-7 | Текущее состояние watchdog (последний замер по путям, состояние алерта, время последнего алерта, порог/период) наблюдаемо в `GET /queue` (read-only). | FR-6, AC-7 | +| BR-8 | Watchdog стартует/останавливается вместе с приложением (в `main.lifespan`) и не требует ручного запуска. | FR-1, AC-8 | + +--- + +## 5. Нефункциональные требования (NFR) + +| ID | Требование | +|----|------------| +| NFR-1 | **never-raise / изоляция:** watchdog — отдельный daemon-поток (паттерн `reconciler`/`job_reaper`); исключение в тике логируется и не прерывает ни поток, ни конвейер. | +| NFR-2 | **Дешевизна:** замер диска — лёгкая операция (предпочтительно stdlib `shutil.disk_usage`, без тяжёлого порождения процессов на каждом тике); период опроса по умолчанию — порядка минут (не секунд), чтобы не создавать нагрузки. | +| NFR-3 | **Корректность источника замера (self-hosting):** измеряется заполнение **хост-ФС**, а не overlay-ФС контейнера. Контейнер видит хост-разделы через bind-mount'ы (`/repos`, `/app/data`); замер обязан отражать раздел(ы), которые реально заполняются на хосте (см. §6). | +| NFR-4 | **Нулевая регрессия:** при выключенном kill-switch поведение приложения идентично текущему; enduro-trails и конвейер не затрагиваются. | +| NFR-5 | **Инварианты неизменны:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, существующие таблицы-контракты БД — не меняются. Допустимо не вводить новую миграцию (состояние watchdog — best-effort, может жить в памяти). | +| NFR-6 | **Self-hosting безопасность:** watchdog только **читает** заполнение и **шлёт** уведомление — не выполняет действий над диском/контейнером, не рестартит прод. | + +--- + +## 6. Допущения и ограничения + +- **Видимость хост-диска из контейнера.** Оркестратор бежит в контейнере с `network_mode: host` и + bind-mount'ами `/home/slin/repos → /repos`, `./data → /app/data`, `/var/run/docker.sock` + (`docs/operations/INFRA.md`). Замер `shutil.disk_usage()`/`df` по **смонтированному пути** + (`/repos`, `/app/data`) отражает заполнение **хост-раздела**, который этот путь подмонтировал — + именно той ФС, что переполнилась 07.06. Замер по `/` (overlay контейнера) **нерепрезентативен** и + не должен использоваться как источник истины. +- **Один заполняющийся раздел.** На mva154, вероятно, рабочие данные (`/home/slin/repos`, + `./data`, Docker) лежат на одном host-разделе; набор отслеживаемых путей по умолчанию должен + покрывать его и при совпадении физического устройства не дублировать алерт (дедуп по устройству — + желательное, не блокирующее требование; решение — за архитектором). +- **Best-effort алертинг.** Доставка Telegram не гарантирована (та же `send_telegram`, never-raise); + watchdog — ранний сигнал, не SLA-гарантия. Состояние анти-спама может быть in-memory (после + рестарта допустим повторный алерт, если всё ещё выше порога — это безопасно). +- **Порог 85%** — зафиксирован Владельцем как дефолт; конфигурируем (BR-5) на случай тюнинга. +- **Только сигнал, не лечение.** Авто-освобождение места — вне объёма (§2.2). + +--- + +## 7. Критерии успеха (резюме; детали — 03-acceptance-criteria.md) +- AC-1 watchdog периодически измеряет заполнение хост-ФС и стартует с приложением. +- AC-2 при ≥85% оператор получает Telegram-алерт с действенными деталями. +- AC-3 анти-спам: один алерт на пересечение + ограниченное повторение, не на каждом тике. +- AC-4 возврат ниже порога → сброс состояния + recovery-сообщение. +- AC-5 порог/период/пути/kill-switch конфигурируемы; выключение → нулевая регрессия. +- AC-6 любой сбой watchdog не роняет конвейер (never-raise). +- AC-7 состояние watchdog видно в `GET /queue`. + +--- + +## 8. Риски (детали — 10-tech-risks.md, заполняет архитектор) +- **R-1** — замер по неверной ФС (overlay `/` контейнера вместо хост-раздела) → ложно-низкое + заполнение → watchdog «молчит» при реально полном хосте (повтор инцидента 07.06). Митигировать: + замер по bind-mount-путям хост-разделов (NFR-3). +- **R-2** — спам-алерты на каждом тике при длительном превышении порога → шум, оператор глохнет к + сигналу. Митигировать: анти-спам/cooldown (BR-3). +- **R-3** — порог 85% слишком близок к 100% при быстром росте (один большой build/worktree) → + оператор не успевает среагировать. Зафиксирован как дефолт Владельцем; конфигурируемость (BR-5) + оставляет рычаг. Возможный follow-up — второй «критический» порог (напр. 95%) с более громким + алертом (кандидат, не в объёме). +- **R-4** — исчерпание **inode** (а не байтов) тоже валит ФС, но не ловится замером по %-байтам. + Кандидат на расширение (вне объёма ORCH-063). +- **R-5** — `df`/субпроцесс на каждом тике — лишняя нагрузка; предпочесть stdlib (NFR-2). diff --git a/docs/work-items/ORCH-063/02-trz.md b/docs/work-items/ORCH-063/02-trz.md new file mode 100644 index 0000000..8913f5a --- /dev/null +++ b/docs/work-items/ORCH-063/02-trz.md @@ -0,0 +1,186 @@ +--- +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. diff --git a/docs/work-items/ORCH-063/03-acceptance-criteria.md b/docs/work-items/ORCH-063/03-acceptance-criteria.md new file mode 100644 index 0000000..924dcff --- /dev/null +++ b/docs/work-items/ORCH-063/03-acceptance-criteria.md @@ -0,0 +1,132 @@ +--- +work_item: ORCH-063 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-063 — INFRA: мониторинг диска mva154 + алерт при >85% + +Work Item: **ORCH-063** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам. + +--- + +## AC-1 — Heartbeat-демон запускается с приложением + +**Условие:** фоновый disk-watchdog периодически измеряет заполнение диска и стартует вместе с +приложением без ручного запуска. +- **PASS:** есть daemon-поток (паттерн `reconciler`/`job_reaper`: `threading.Thread(daemon=True)` + + `threading.Event`), стартующий в `main.lifespan` (после `reaper.start()`) и останавливающийся на + shutdown; период замера = `disk_monitor_interval_s`; есть метод `status()`. +- **FAIL:** watchdog не стартует автоматически; блокирующий `time.sleep` без чистого stop; замер + выполняется в обработчике вебхука/в горячем пути конвейера, а не в отдельном демоне. + +--- + +## AC-2 — Алерт при заполнении ≥ порога + +**Условие:** при заполнении отслеживаемого пути ≥ `disk_monitor_threshold_pct` (дефолт 85%) оператор +получает Telegram-алерт с действенными деталями. +- **PASS:** при `used_pct ≥ threshold` вызывается `send_telegram` (notifying, не silent) с + сообщением, содержащим путь/точку монтирования, занято %, свободно (ГБ или %) и порог. +- **FAIL:** алерт не отправляется при превышении; отправляется silent (`disable_notification=True`) + и не пингует; сообщение без действенных деталей (нет %/пути/свободно). + +--- + +## AC-3 — Анти-спам: не на каждом тике + +**Условие:** при длительном превышении порога алерт не дублируется на каждом тике. +- **PASS:** алерт отправляется при пересечении порога (переход «ниже→на/выше»); пока заполнение + остаётся выше порога, повторный алерт шлётся не чаще `disk_monitor_realert_s`. Решение об отправке + выражено чистой функцией от `(current_pct, threshold, previous_state, now)` и покрыто юнит-тестом. +- **FAIL:** алерт шлётся на каждом тике при стабильном превышении; нет cooldown/состояния; логика + отправки не тестируема (зашита в поток с реальным таймером). + +--- + +## AC-4 — Recovery при возврате ниже порога + +**Условие:** при возврате заполнения ниже порога состояние сбрасывается и приходит однократное +сообщение восстановления. +- **PASS:** переход «выше→ниже порога» сбрасывает состояние алерта и отправляет ровно одно + recovery-сообщение «диск ниже порога»; последующее новое превышение снова алертит (цикл повторяем). +- **FAIL:** после спада ниже порога состояние не сбрасывается (новое превышение молчит из-за + «залипшего» cooldown); recovery шлётся повторно на каждом тике ниже порога. + +--- + +## AC-5 — Конфигурируемость и kill-switch + +**Условие:** порог, период, период повтора, пути и включение конфигурируемы; выключение даёт нулевую +регрессию. +- **PASS:** в `config.py` есть `disk_monitor_enabled` / `disk_monitor_interval_s` / + `disk_monitor_threshold_pct` / `disk_monitor_realert_s` / `disk_monitor_paths` (с env-маппингом); + при `disk_monitor_enabled=False` демон не стартует, `/queue`-блок отдаёт `{"enabled": false}`, + поведение приложения идентично текущему. Новые env задокументированы в `.env.example`. +- **FAIL:** значения захардкожены; нет kill-switch; при выключении меняется поведение конвейера; + env не задокументированы. + +--- + +## AC-6 — never-raise (изоляция от конвейера) + +**Условие:** любой сбой watchdog не роняет и не блокирует конвейер. +- **PASS:** замер по несуществующему/недоступному пути, ошибка `send_telegram`, исключение в тике — + логируются и **не** пробрасываются; демон продолжает работу; конвейер и enduro-trails не + затронуты. Покрыто тестом (замер по битому пути / исключение в отправке → тик не падает). +- **FAIL:** исключение в тике останавливает поток или всплывает в приложение; недоступный путь + роняет замер всех путей. + +--- + +## AC-7 — Наблюдаемость в `GET /queue` + +**Условие:** состояние watchdog видно в `GET /queue`. +- **PASS:** ответ `GET /queue` содержит аддитивный блок `disk_monitor` с `enabled`, `threshold_pct`, + `interval_s`, `paths` (последний замер: `used_pct`, свободно), `alerting`, `last_alert_at`; + существующие ключи ответа не изменены; блок never-raise (при ошибке — минимальный словарь). +- **FAIL:** блока нет; изменены/сломаны существующие ключи `/queue`; блок может выбросить исключение. + +--- + +## AC-8 — Корректный источник замера (хост-ФС) + +**Условие:** замер отражает заполнение хост-раздела, а не overlay-ФС контейнера. +- **PASS:** дефолтный набор путей — смонтированные хост-пути (`/repos`, `/app/data`); замер по ним + репрезентативен для заполняющегося хост-раздела. Источником истины **не** является `shutil.disk_usage("/")` + (overlay контейнера). +- **FAIL:** мониторится только `/` контейнера → ложно-низкое заполнение при реально полном хосте + (риск повтора инцидента 07.06). + +--- + +## AC-9 — Документация обновлена (golden source) + +**Условие:** документация обновлена в том же PR (CLAUDE.md §2; reviewer-ось). +- **PASS:** обновлены `docs/architecture/README.md` (компонент + блок `/queue`), + `docs/operations/INFRA.md` (что мониторится, порог, как отключить, реакция на алерт), + `.env.example` (новые `ORCH_DISK_*`), `CHANGELOG.md` (`feat:`); создан + `docs/work-items/ORCH-063/06-adr/ADR-001-*.md`. +- **FAIL:** функционал добавлен, но обзорные/операционные доки или ADR не обновлены. + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / BR-8 / FR-1 | +| AC-2 | BR-2 / FR-2 / FR-3 | +| AC-3 | BR-3 / FR-4 | +| AC-4 | BR-4 / FR-4 | +| AC-5 | BR-5 / FR-5 / NFR-4 | +| AC-6 | BR-6 / NFR-1 | +| AC-7 | BR-7 / FR-6 | +| AC-8 | NFR-3 / FR-2 | +| AC-9 | CLAUDE.md §2 (документация = golden source) | diff --git a/docs/work-items/ORCH-063/04-test-plan.yaml b/docs/work-items/ORCH-063/04-test-plan.yaml new file mode 100644 index 0000000..ca6e042 --- /dev/null +++ b/docs/work-items/ORCH-063/04-test-plan.yaml @@ -0,0 +1,92 @@ +work_item: ORCH-063 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +title: "Disk-watchdog mva154: heartbeat-замер + Telegram-алерт при >85%" +framework: pytest +scope: > + Покрывается: чистая логика решения об алерте (порог/анти-спам/recovery), замер заполнения + по путям с дедупом/never-raise, формат алерт-сообщения, daemon start/stop/status, + блок disk_monitor в GET /queue, нулевая регрессия при выключенном kill-switch. + Вне покрытия: реальная отправка в Telegram (мокается), реальное заполнение диска mva154, + внешние системы мониторинга, авто-очистка диска (вне объёма ORCH-063). +notes: > + Время и Telegram-транспорт инъецируются/мокаются: now-провайдер для cooldown, + monkeypatch send_telegram для перехвата вызовов. shutil.disk_usage мокается для задания + used_pct без реального диска. Полный регресс tests/ должен оставаться зелёным. + Имена модулей/функций финализирует архитектор (ADR-001) — module в TC ориентировочны. + +tests: + - id: TC-01 + type: unit + description: "Решение алертить: used_pct >= threshold и состояние было 'ниже' -> should_alert=True (пересечение порога)." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-02 + type: unit + description: "Анти-спам: used_pct >= threshold, состояние уже 'выше', с последнего алерта прошло < realert_s -> should_alert=False (не на каждом тике)." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-03 + type: unit + description: "Повтор по cooldown: 'выше' порога, прошло >= realert_s с последнего алерта -> should_alert=True (повторный алерт)." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-04 + type: unit + description: "Recovery: переход used_pct < threshold из состояния 'выше' -> сброс состояния + ровно одно recovery-сообщение; ниже порога устойчиво -> recovery не повторяется." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-05 + type: unit + description: "Граница порога: used_pct ровно == threshold трактуется как превышение (>= порога алертит); used_pct == threshold-1 -> молчит." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-06 + type: unit + description: "Замер по путям: для каждого пути считается used_pct/free через (мок) shutil.disk_usage; совпадающие по устройству пути дедуплицируются (одно срабатывание)." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-07 + type: unit + description: "never-raise: недоступный/несуществующий путь и исключение в send_telegram логируются и не пробрасываются; тик завершается, демон жив." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-08 + type: unit + description: "Формат алерта: сообщение содержит путь/точку монтирования, used_pct, свободно (ГБ или %) и порог; отправляется notifying (disable_notification не True)." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-09 + type: unit + description: "Kill-switch: при disk_monitor_enabled=False демон не стартует в lifespan (или start() — no-op); замеры/алерты не выполняются." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-10 + type: unit + description: "status(): возвращает dict с enabled/threshold_pct/interval_s/paths(последний замер)/alerting/last_alert_at; never-raise при отсутствии замеров." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-11 + type: integration + description: "GET /queue содержит аддитивный блок disk_monitor с ожидаемыми ключами; существующие ключи ответа (counts/reconcile/reaper/serial_gate/...) не изменены." + module: tests/test_disk_watchdog.py + expected: PASS + + - id: TC-12 + type: integration + description: "Тик демона при замоканном высоком заполнении (>=85%) вызывает send_telegram один раз; при выключенном флаге GET /queue отдаёт disk_monitor.enabled=false и алертов нет (нулевая регрессия)." + module: tests/test_disk_watchdog.py + expected: PASS