analyst(ET): auto-commit from analyst run_id=483

This commit is contained in:
2026-06-09 18:34:18 +03:00
committed by orchestrator-deployer
parent 8c97a6ab1c
commit 8ace9f880d
4 changed files with 557 additions and 0 deletions

View File

@@ -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).

View File

@@ -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.

View File

@@ -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) |

View File

@@ -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