diff --git a/docs/work-items/ORCH-099/01-brd.md b/docs/work-items/ORCH-099/01-brd.md new file mode 100644 index 0000000..c8e9ae6 --- /dev/null +++ b/docs/work-items/ORCH-099/01-brd.md @@ -0,0 +1,141 @@ +--- +work_item: ORCH-099 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-099 — FND/F1a: лёгкий `/metrics` в орке (отдать сырьё) + +Work Item: **ORCH-099** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Задача — фундаментный кирпич **F1a** домена 0 «Фундамент» эпика автономного саморазвития +(`docs/epics/self-evolution.md`). Архитектурная рамка наблюдаемости **зафиксирована заказчиком +(Слава, 09.06)** и для аналитика — установленный факт, не предмет переизобретения: + +- **C-1/C-1б:** наблюдатель ОТДЕЛЁН от наблюдаемого. Мониторинг живёт в **отдельном sidecar-контейнере** + (`watchdog/`, рантайм — свой Dockerfile + сервис в compose), а НЕ внутри орка. Если орк + упал/завис/съел память — sidecar жив и репортит это. +- **C-2/C-3:** без внешнего плеча, тонкий стек (хост впритык: RAM 171Mi free, диск 92% — НЕ + Grafana/Prometheus). +- **Разделение ответственности:** орк отдаёт **только сырьё** (лёгкий read-only `/metrics` — свои + внутренние данные, которые знает только он сам), БЕЗ логики мониторинга/порогов/алертов/хранения. + Мозг (пороги, алерты, свой Telegram-канал, история) — это **F1b (sidecar)**, отдельная задача. + +**Боль, которую закрывает задача.** Сегодня у орка нет машинного «сырья» о самом себе в одной +точке. `/health` отдаёт лишь `{"status":"ok"}`, `/status` — список активных задач, `/queue` — +богатый, но «человеческий» снимок очереди, перемешанный с конфигом демонов. Ни один из них не даёт +sidecar'у структурированный, стабильный КОНТРАКТ для детекта: застрявшая стадия, зависший агент +(liveness по pid/CPU), деградация очереди (breaker open, рост failed), всплеск стоимости токенов. +Без этого источника весь домен наблюдаемости (F1b и далее) слеп и не может стартовать. + +**Self-hosting контекст.** Орк дорабатывает сам себя; прод-контейнер общий для всех проектов. +`/metrics` обязан быть **строго read-only** и **never-raise** — он не должен ни при каких входных +данных уронить или притормозить прод, обслуживающий enduro-trails. + +## 2. Объём (scope) + +### В объёме +- Новый **read-only** HTTP-эндпоинт (`GET /metrics`), отдающий JSON-снимок сырья о самом орке. +- Четыре раздела сырья: **активные стадии задач**, **очередь jobs**, **agent-liveness**, + **стоимость/токены** (`agent_runs`). +- Новый leaf-модуль `src/metrics.py` — сборка снимка из БД (чистый, never-raise, без побочных + эффектов), по образцу `snapshot()`-функций (`serial_gate`/`task_deps`/`cancel`). +- Документирование формата `/metrics` как **контракта для sidecar (F1b)** в + `docs/architecture/README.md` + запись в `CHANGELOG.md`. +- Pytest-покрытие: структура ответа, never-raise, read-only-инвариант. + +### Вне объёма +- ❌ Любая логика мониторинга: пороги, алерты, Telegram, оценка «застрял/завис», хранение истории + — это **F1b (sidecar)**. +- ❌ Сам sidecar-контейнер (`watchdog/`, Dockerfile, compose-сервис) — отдельная задача F1b. +- ❌ Хостовые/контейнерные/внешние метрики (диск/RAM/CPU хоста, docker.sock, пинг + Plane/Gitea/Anthropic) — их собирает sidecar, не орк. +- ❌ Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схемы БД / любых machine-verdict + ключей. +- ❌ Дашборд/UI (упомянут в F1 эпика как отдельный последующий шаг). +- ❌ Прометей-совместимый text-формат — отдаём JSON (контракт под конкретный sidecar; OpenMetrics + не требование заказчика). + +## 3. Заинтересованные стороны + +- **Заказчик:** Слава (рамки наблюдаемости F1, эпик саморазвития). +- **Прямой потребитель контракта:** будущий sidecar **F1b** (`watchdog/`) — читает `/metrics` по + HTTP. Задача F1b **заблокирована** этой (ORCH-099 — источник контракта). +- **Затрагивается:** прод-инстанс орка (общий с enduro-trails) — поэтому жёсткое требование + read-only/never-raise. +- **Принимает результат:** reviewer/tester конвейера + Слава как владелец рамок. + +## 4. Бизнес-требования (BR) + +- **BR-1 — Эндпоинт сырья.** Орк предоставляет HTTP `GET /metrics`, отдающий JSON с четырьмя + разделами: (a) активные стадии задач, (b) очередь jobs, (c) agent-liveness, (d) стоимость/токены. + Состав полей каждого раздела — см. TRZ §3 (FR-1…FR-4). +- **BR-2 — Стадии задач.** По каждой незавершённой задаче отдаётся `work_item`, текущая `stage` и + «как давно в стадии» (секунды) — сырьё для детекта застреваний sidecar'ом. +- **BR-3 — Очередь jobs.** Отдаются счётчики по статусам (`queued`/`running`/`failed`/…), глубина + очереди, информация о ретраях и состояние circuit-breaker'а — сырьё для детекта деградации. +- **BR-4 — Agent-liveness.** По каждому running-job отдаётся `agent`, `run_id`, `pid`, `runtime_s` + и сырьё для alive-детекта (CPU-тики pid либо данные, по которым sidecar посчитает CPU-дельту). + sidecar — арбитр «жив/завис»; орк лишь поставляет факты. +- **BR-5 — Стоимость/токены.** Отдаётся текущая (по running-job) и агрегированная стоимость/токены + из `agent_runs` (`cost_usd`, `input/output/cache_*` токены) — сырьё для cost-наблюдаемости (D3). +- **BR-6 — Аддитивность.** Существующие `/health`, `/status`, `/queue` остаются байт-в-байт прежними + по контракту; `/metrics` добавляется рядом, ничего не ломая. +- **BR-7 — Документированный контракт.** Формат `/metrics` зафиксирован в + `docs/architecture/README.md` как стабильный контракт для sidecar (F1b) + `CHANGELOG.md`. + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 — Read-only.** Эндпоинт НИЧЕГО не мутирует: не пишет в БД, не запускает/останавливает + процессы, не рестартит, не дёргает внешние API. Только SELECT'ы + чтение in-memory-снимков + демонов. +- **NFR-2 — Never-raise (по полям).** Любая ошибка при сборе отдельного поля/раздела → это поле + получает `null` (или раздел — безопасный дефолт), но эндпоинт **возвращает 200 и валидный JSON**, + никогда не 500. Эталон — `serial_gate.snapshot()` с fallback в `except`. +- **NFR-3 — Лёгкость.** Только быстрые запросы к локальной SQLite + чтение уже посчитанных + in-memory снапшотов; без тяжёлых вычислений, без сетевых вызовов, без сканирования файлов/git. + Цель — единичные мс на типовом объёме (десятки задач/jobs). +- **NFR-4 — Self-hosting-безопасность.** Эндпоинт физически не способен повлиять на прод-конвейер + (следствие NFR-1) — безопасен на общем инстансе с enduro-trails. +- **NFR-5 — Совместимость БД/гейтов.** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / + machine-verdict ключи / схема БД — НЕ трогаются. Задача читает существующие таблицы + (`tasks`/`jobs`/`agent_runs`) и существующие in-memory снапшоты. +- **NFR-6 — Стабильность контракта.** Формат — аддитивный и версионируемый (поле `schema_version`), + чтобы будущие расширения не ломали уже написанный sidecar. + +## 6. Допущения и ограничения + +- **Данные уже есть в БД.** Все нужные поля присутствуют: `tasks(stage, work_item_id, updated_at, + created_at)`, `jobs(status, attempts, max_attempts, transient_attempts, available_at, pid, + run_id)`, `agent_runs(agent, started_at, finished_at, model, effort, cost_usd, input_tokens, + output_tokens, cache_read_tokens, cache_creation_tokens)`. **Новые колонки/таблицы не нужны.** +- **Breaker-состояние — in-memory** (`queue_worker.worker.status()` / `CircuitBreaker.snapshot()`); + читается без БД. +- **CPU-тики pid** читаются из `/proc//stat` (Linux прод-контейнер). Допущение: контейнер + Linux; при отсутствии/гонке (процесс уже умер) — поле `null` (NFR-2), НЕ ошибка. Это согласуется + с рамкой C-1: «орк лёг → endpoint недоступен = сам сигнал тревоги» — детект делает sidecar. +- **Арбитраж liveness — на стороне sidecar.** Орк не решает «завис/жив»; он лишь отдаёт `pid`, + `runtime_s` и (по возможности) CPU-тики; sidecar считает дельту между опросами. +- **Формат — JSON**, не OpenMetrics/Prometheus (рамка C-3: тонкий кастомный sidecar, не Prometheus). + +## 7. Критерии успеха + +`GET /metrics` отдаёт лёгкий, read-only, never-raise JSON с четырьмя разделами сырья; +`/health`/`/status`/`/queue` не сломаны; формат задокументирован как контракт sidecar; pytest +зелёный. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски + +- Гонка чтения `/proc//stat` (процесс умер между выборкой job и чтением proc) → закрывается + NFR-2 (`null`, не ошибка). +- Расхождение контракта `/metrics` и ожиданий sidecar (F1b) → закрывается BR-7 (контракт в одном + репо, документирован) + `schema_version` (NFR-6). +- Соблазн «протащить» в `/metrics` логику алертинга → закрывается scope-границей (вне объёма) и + NFR-1. + +Детальная оценка технических рисков — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-099/02-trz.md b/docs/work-items/ORCH-099/02-trz.md new file mode 100644 index 0000000..81b169c --- /dev/null +++ b/docs/work-items/ORCH-099/02-trz.md @@ -0,0 +1,173 @@ +--- +work_item: ORCH-099 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-099 — FND/F1a: лёгкий `/metrics` в орке (отдать сырьё) + +Work Item: **ORCH-099** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование/решения (формат полей liveness, способ чтения CPU, версионирование +> контракта) — задача архитектора (`06-adr/`). + +## 1. Сводка изменения + +Добавить read-only HTTP-эндпоинт `GET /metrics`, отдающий JSON-снимок «сырья» о самом орке для +будущего sidecar (F1b): активные стадии задач, очередь jobs, agent-liveness, стоимость/токены. +Логика сборки выносится в **новый leaf-модуль** `src/metrics.py` (чистая функция-сборщик, never-raise, +без побочных эффектов — по образцу `serial_gate.snapshot()`/`task_deps.snapshot()`/`cancel.snapshot()`). +Эндпоинт в `src/main.py` — тонкая обёртка над сборщиком, в том же стиле, что `GET /queue` +(`src/main.py`, дикт с разделами). Никаких изменений `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схемы +БД/machine-verdict ключей. Только чтение существующих таблиц и существующих in-memory-снапшотов. + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `src/metrics.py` | **создать** — leaf-сборщик снимка из БД (`build_metrics() -> dict`, never-raise) | +| `src/main.py` | изменить — добавить `@app.get("/metrics")` (тонкая обёртка над `metrics.build_metrics()`) | +| `src/db.py` | изменить (при необходимости) — добавить read-only helper(ы) для агрегатов `agent_runs` (напр. `agent_cost_totals()`); существующие `job_status_counts`/`get_running_jobs`/`recent_jobs`/`get_active_tasks_for_reconcile` переиспользуются как есть | +| `docs/architecture/README.md` | изменить — задокументировать контракт `/metrics` (формат для sidecar F1b) | +| `CHANGELOG.md` | изменить — запись `## [Unreleased]` (ORCH-099) | +| `tests/test_metrics.py` | **создать** — pytest на структуру/never-raise/read-only | + +**Существующие источники данных (переиспользуются, НЕ дублируются):** +- `db.get_active_tasks_for_reconcile()` — задачи с `stage != 'done'` + вычисленный `age_s` + (секунды с `updated_at`). Базис для раздела стадий. +- `db.job_status_counts()` — `{queued, running, done, failed}` из `jobs`. +- `db.get_running_jobs()` — running-jobs с `running_age_s`, плюс джойн на `agent_runs` (`agent`, + `run_id`, `pid`, `started_at`, `model`, `effort`). Базис для liveness. +- `queue_worker.worker.status()` / `worker.breaker.snapshot()` — breaker-состояние in-memory + (`state`/`consecutive_transient`/`pause_remaining_s`), `max_concurrency`, `poll_interval`. + +## 3. Функциональные требования + +### FR-1 — Раздел `stages` (активные стадии задач) — BR-2 + +Список активных (незавершённых) задач. По каждой: +- `work_item` — `tasks.work_item_id`. +- `stage` — `tasks.stage` (значение слоя A, машина стадий). +- `age_in_stage_s` — целое; секунды с `tasks.updated_at` (= момент последней смены стадии). + Источник вычисления — SQL `CAST(strftime('%s','now') - strftime('%s', updated_at) AS INTEGER)`, + как в `get_active_tasks_for_reconcile`. +- `repo` — `tasks.repo` (sidecar мультипроектный; нужно отличать orchestrator от enduro-trails). +- (опционально) `task_id`, `created_age_s` (общий возраст задачи). + +Инвариант: выборка только `stage NOT IN ('done', 'cancelled')` (терминальные исключены — см. +ORCH-090: множество терминалов `{done, cancelled}`). Пустой список — валидный ответ. + +### FR-2 — Раздел `queue` (очередь jobs) — BR-3 + +- `counts` — `db.job_status_counts()` (`queued`/`running`/`done`/`failed`); при наличии — + добавить `cancelled` (ORCH-090 терминал). +- `depth` — глубина очереди = число `queued`-jobs, готовых к выдаче (можно вернуть как + `counts.queued`; при желании — отдельно «доступные сейчас» с учётом `available_at <= now`). +- `retries` — сырьё по ретраям: сумма/список `attempts` vs `max_attempts` и `transient_attempts` + по незавершённым jobs; как минимум агрегат «сколько jobs в backoff» (`available_at > now`). +- `breaker` — `worker.breaker.snapshot()`: `state` (`closed`/`open`/`half-open`), + `consecutive_transient`, `pause_remaining_s`. +- `max_concurrency` — `worker.max_concurrency`. + +Инвариант: ни одно поле не обязано существовать ценой падения — недоступный breaker +(например, worker не инициализирован в тесте) → `breaker: null`, не 500 (NFR-2). + +### FR-3 — Раздел `agents` (agent-liveness) — BR-4 + +Список running-jobs (из `db.get_running_jobs()`), по каждому: +- `agent` — `agent_runs.agent` (через джойн; роль: analyst/architect/developer/…). +- `run_id` — `jobs.run_id` (= `agent_runs.id`). +- `job_id` — `jobs.id`. +- `pid` — `jobs.pid` (может быть `null`, если процесс ещё не застамплен / уже завершён). +- `runtime_s` — `running_age_s` из `get_running_jobs` (секунды с `jobs.started_at`); как + альтернатива — секунды с `agent_runs.started_at`. Решение о базисе — за архитектором (ADR). +- **Сырьё для alive-детекта** — одно из (выбор реализации — ADR архитектора, BR-4 допускает оба): + - вариант A: `cpu_ticks` — суммарные utime+stime из `/proc//stat` (поля 14–15), плюс + `clk_tck` (`os.sysconf("SC_CLK_TCK")`), чтобы sidecar посчитал CPU-дельту между опросами; + - вариант B: орк сам не считает дельту (он опрашивается стейтлесс sidecar'ом) — отдаёт только + сырые тики + временную метку выборки. +- `model`, `effort` — `agent_runs.model`/`effort` (контекст стоимости). + +Инвариант (NFR-2): `pid is None` ИЛИ `/proc/` отсутствует/гонка (процесс умер) → +`cpu_ticks: null` для этого агента, остальные поля и весь эндпоинт целы. НЕ бросать, НЕ ждать. + +### FR-4 — Раздел `cost` (стоимость/токены) — BR-5 + +- `running` — по каждому running-job текущие накопленные значения из `agent_runs`, если уже + застамплены (часто `null` до завершения — токены/cost парсятся из CLI-JSON в `_monitor_agent` + по окончании). Допустимо отдавать `null` для незавершённых — это честное сырьё. +- `aggregate` — агрегаты по `agent_runs`: суммарные `cost_usd`, `input_tokens`, `output_tokens`, + `cache_read_tokens`, `cache_creation_tokens`. Желателен срез: всего + за последние N (или + по `repo`). Реализуется новым read-only helper'ом `db.agent_cost_totals()` (чистый SELECT + с `COALESCE(SUM(...),0)`). + +Инвариант: пустая `agent_runs` → нули, не ошибка. + +### FR-5 — Конверт ответа (envelope) — BR-1, BR-6, NFR-6 + +`GET /metrics` возвращает JSON: +```json +{ + "schema_version": 1, + "generated_at": "", + "stages": [ ... ], + "queue": { ... }, + "agents": [ ... ], + "cost": { "running": [...], "aggregate": {...} } +} +``` +- `schema_version` — целое; точка стабильности контракта для sidecar (NFR-6). Стартовое значение + и политика инкремента — за архитектором. +- `generated_at` — метка времени снимка (нужна sidecar'у для расчёта дельт). +- Точные имена ключей разделов/полей фиксируются в `docs/architecture/README.md` (BR-7) и являются + контрактом; reviewer/tester сверяют ответ с документом. + +### FR-6 — Never-raise сборщик — NFR-2 + +`metrics.build_metrics()` строит ответ по-раздельно; каждый раздел — в своём `try/except`, в +`except` пишет `logger.warning(...)` и подставляет безопасный дефолт (`null`/`[]`/`{}`). Функция +**никогда** не пробрасывает исключение. Эндпоинт `main` дополнительно не нуждается в обработке, но +обязан вернуть результат сборщика как есть. Эталон — `serial_gate.snapshot()`. + +## 4. Изменения API + +**Новый эндпоинт:** +- `GET /metrics` → `200 application/json`, тело — конверт FR-5. Без параметров. Без аутентификации + сверх существующей (тот же уровень, что `/queue`/`/status`). Read-only. + +**Изменённые эндпоинты:** Нет. `/health`, `/status`, `/queue`, `/webhook/*` — без изменений +(BR-6). Регресс-проверка: существующие тесты эндпоинтов остаются зелёными. + +## 5. Изменения схемы БД + +**Нет.** Новые таблицы/колонки/индексы/миграции не вводятся. Используются существующие +`tasks`/`jobs`/`agent_runs` и их колонки (перечислены в §2). Допускается добавление **read-only** +helper-функций в `src/db.py` (например `agent_cost_totals()`) — это код, не схема; `CREATE`/`ALTER` +не выполняются. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема — байт-в-байт прежние (NFR-5). + +## 6. Требования к новым/изменённым QG checks + +**Нет.** `/metrics` — наблюдаемость, не гейт конвейера. `QG_CHECKS` / `check_*` / `_parse_*` / +machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/ +`coverage_status:`) — НЕ трогаются. Новых артефактов pipeline (`NN-*.md`) задача не создаёт. + +## 7. Совместимость / регресс + +- **Аддитивность:** новый модуль (`src/metrics.py`) + новый эндпоинт + read-only helper(ы). + Существующий код путей конвейера не модифицируется. +- **Read-only / never-raise:** по конструкции (NFR-1/NFR-2) эндпоинт не влияет на состояние и не + падает → нулевой риск для прод-конвейера, общего с enduro-trails (NFR-4). +- **Kill-switch:** жёсткий флаг не обязателен (эндпоинт инертен и не подключён к конвейеру). Если + архитектор сочтёт нужным — допустим конфиг-флаг включения `/metrics` (по образцу snapshot-флагов), + но это НЕ требование BRD; дефолт — эндпоинт доступен. +- **Обратимость:** удаление эндпоинта/модуля полностью откатывает изменение без следов в БД/схеме. +- **Контракт sidecar:** `schema_version` + документ в README обеспечивают, что F1b не сломается при + будущих аддитивных расширениях (NFR-6). +- **Артефакты pipeline, создаваемые/обновляемые задачей:** `01-brd.md`, `02-trz.md`, + `03-acceptance-criteria.md`, `04-test-plan.yaml` (analysis); далее — `06-adr/` (architect), + обновление `docs/architecture/README.md` и `CHANGELOG.md` (developer в том же PR — правило + «доки = golden source»). diff --git a/docs/work-items/ORCH-099/03-acceptance-criteria.md b/docs/work-items/ORCH-099/03-acceptance-criteria.md new file mode 100644 index 0000000..b2c15c4 --- /dev/null +++ b/docs/work-items/ORCH-099/03-acceptance-criteria.md @@ -0,0 +1,127 @@ +--- +work_item: ORCH-099 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-099 — FND/F1a: лёгкий `/metrics` в орке + +Work Item: **ORCH-099** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** +(что считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и по ответу +эндпоинта. + +--- + +## AC-1 — Эндпоинт `/metrics` отдаёт четыре раздела сырья + +**Условие:** `GET /metrics` возвращает `200` и JSON с разделами `stages`, `queue`, `agents`, `cost` +(плюс конверт `schema_version` / `generated_at`), с полями из TRZ §3. +- **PASS:** ответ — валидный JSON-объект; присутствуют ключи `schema_version`, `generated_at`, + `stages` (список; элемент содержит `work_item`, `stage`, `age_in_stage_s`, `repo`), `queue` + (содержит `counts`, `breaker`, `max_concurrency`, сырьё ретраев), `agents` (список; элемент + содержит `agent`, `run_id`, `pid`, `runtime_s` и поле сырья CPU-liveness), `cost` (содержит + `aggregate` с суммами `cost_usd`/`input_tokens`/`output_tokens`/`cache_read_tokens`/ + `cache_creation_tokens`). +- **FAIL:** отсутствует любой из четырёх разделов; в `agents` нет `pid`/`runtime_s`; в `stages` нет + «как давно в стадии»; в `cost` нет агрегата токенов/стоимости; ответ не JSON или статус ≠ 200. + +--- + +## AC-2 — Аддитивность: `/health`, `/status`, `/queue` не сломаны + +**Условие:** существующие эндпоинты сохраняют прежний контракт. +- **PASS:** `GET /health` → `{"status":"ok", ...}`; `GET /status` → `{"active_tasks":[...]}`; + `GET /queue` отдаёт прежний набор ключей; существующие тесты эндпоинтов (`tests/test_queue_endpoint.py` + и пр.) зелёные без модификации их ожиданий. +- **FAIL:** изменён/удалён любой существующий ключ ответа `/health`/`/status`/`/queue`; пришлось + править существующие тесты под новый контракт; регресс в этих эндпоинтах. + +--- + +## AC-3 — Лёгкость и быстрая выборка + +**Условие:** эндпоинт лёгкий — только быстрые локальные SQL + чтение in-memory снапшотов, без +тяжёлых вычислений и сетевых вызовов. +- **PASS:** в коде `src/metrics.py` нет сетевых вызовов (HTTP/Plane/Gitea/Anthropic), нет запуска + подпроцессов кроме безопасного чтения `/proc//stat`, нет сканирования git/файлового дерева; + данные берутся из существующих helper'ов БД и `worker`-снапшота; на типовом объёме ответ + формируется без заметной задержки. +- **FAIL:** эндпоинт делает сетевой запрос, запускает агента/тяжёлый процесс, сканирует worktree/git + или выполняет дорогие агрегаты, заметно тормозящие ответ. + +--- + +## AC-4 — Never-raise (ошибка поля → `null`, эндпоинт не падает) + +**Условие:** любая ошибка сбора отдельного поля/раздела не роняет эндпоинт. +- **PASS:** при недоступном источнике (например, `worker` не инициализирован, `pid` уже мёртв, + `/proc/` отсутствует, пустые таблицы) соответствующее поле получает `null`/безопасный дефолт, + а `GET /metrics` всё равно возвращает `200` и валидный JSON; есть тест, симулирующий сбой раздела + и проверяющий 200 + `null` в этом поле. +- **FAIL:** при любом из перечисленных условий эндпоинт возвращает `500` / бросает исключение / + возвращает невалидный JSON. + +--- + +## AC-5 — Read-only (ничего не меняет; гейты/схема не тронуты) + +**Условие:** эндпоинт и модуль строго read-only; конвейерные инварианты целы. +- **PASS:** `src/metrics.py` и обработчик `/metrics` не выполняют `INSERT`/`UPDATE`/`DELETE`/`CREATE`/ + `ALTER`, не запускают/останавливают процессы, не рестартят, не мутируют состояние демонов; + `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, machine-verdict ключи и схема БД (`tasks`/`jobs`/ + `agent_runs` и пр.) — без изменений в диффе; повторный вызов `/metrics` не меняет состояние БД + (тест: снимок БД до/после идентичен). +- **FAIL:** дифф трогает `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схему/machine-verdict; модуль + выполняет любую запись/мутацию; вызов эндпоинта меняет состояние. + +--- + +## AC-6 — agent-liveness содержит сырьё для alive-детекта + +**Условие:** по каждому running-job отдаётся идентификация процесса и сырьё для CPU-детекта +sidecar'ом. +- **PASS:** для running-job ответ содержит `agent`, `run_id`, `pid`, `runtime_s` и поле сырья + CPU-liveness (например `cpu_ticks` из `/proc//stat` + базис тиков `clk_tck`, либо + эквивалент по решению ADR), позволяющее внешнему наблюдателю посчитать CPU-дельту между опросами; + при `pid is None`/мёртвом процессе CPU-поле = `null` (см. AC-4), прочие поля целы. +- **FAIL:** liveness-раздел не позволяет sidecar'у отличить «жив» от «завис» (нет ни CPU-сырья, ни + pid+runtime); отсутствуют `run_id`/`pid`; обращение к мёртвому pid роняет эндпоинт. + +--- + +## AC-7 — Контракт задокументирован (для sidecar F1b) + CHANGELOG + +**Условие:** формат `/metrics` зафиксирован как контракт и отражён в журнале изменений. +- **PASS:** в `docs/architecture/README.md` описан формат ответа `/metrics` (разделы, поля, + `schema_version`) как стабильный контракт для sidecar (F1b); в `CHANGELOG.md` есть запись + `## [Unreleased]` с пометкой `ORCH-099`. +- **FAIL:** формат не задокументирован или описан только в коде; нет записи в `CHANGELOG.md`; + документация противоречит фактическому ответу эндпоинта. + +--- + +## AC-8 — pytest зелёный + +**Условие:** новый тест-набор и полный регресс проходят. +- **PASS:** `pytest tests/ -q` зелёный; присутствует `tests/test_metrics.py`, покрывающий структуру + ответа (AC-1), never-raise (AC-4), read-only (AC-5), liveness-сырьё (AC-6) и аддитивность (AC-2). +- **FAIL:** любой тест красный; новые тесты отсутствуют или не покрывают перечисленные критерии. + +--- + +## Сводная матрица AC ↔ FR/BR +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1/BR-2/BR-3/BR-5 / FR-1…FR-5 | +| AC-2 | BR-6 / FR-4 | +| AC-3 | NFR-3 / FR-6 | +| AC-4 | NFR-2 / FR-6 | +| AC-5 | NFR-1/NFR-4/NFR-5 / FR-5 | +| AC-6 | BR-4 / FR-3 | +| AC-7 | BR-7 / FR-5 | +| AC-8 | NFR-3 (валидация) / все FR | diff --git a/docs/work-items/ORCH-099/04-test-plan.yaml b/docs/work-items/ORCH-099/04-test-plan.yaml new file mode 100644 index 0000000..82a260a --- /dev/null +++ b/docs/work-items/ORCH-099/04-test-plan.yaml @@ -0,0 +1,86 @@ +work_item: ORCH-099 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +title: "FND/F1a — лёгкий read-only /metrics: стадии/очередь/agent-liveness/cost" +framework: pytest +scope: > + Покрывается: структура ответа GET /metrics (4 раздела + конверт), never-raise по полям, + read-only инвариант, agent-liveness сырьё (pid/runtime/cpu-тики), агрегаты cost/токенов, + аддитивность (не сломаны /health//status//queue). Вне покрытия: сам sidecar (F1b), + хостовые/контейнерные метрики, пороги/алерты/Telegram. Полный регресс tests/ остаётся зелёным. +notes: > + Тесты идут в новый tests/test_metrics.py. Используется существующий паттерн conftest.py + (autouse fresh_db на tmp_path + init_db, monkeypatch send_telegram). Эндпоинт зовётся как + корутина через asyncio.run(main.metrics()) по образцу tests/test_queue_endpoint.py + (asyncio.run(main.queue())). Read-only проверяется сравнением снимка БД до/после вызова. + Never-raise — monkeypatch источника (worker / helper БД / чтения /proc) на бросающий стаб. + +tests: + - id: TC-01 + type: unit + description: "build_metrics() возвращает dict с ключами schema_version, generated_at, stages, queue, agents, cost (конверт FR-5)." + module: tests/test_metrics.py + expected: PASS + + - id: TC-02 + type: unit + description: "Раздел stages: для задачи со stage!=done/cancelled элемент содержит work_item, stage, age_in_stage_s (int), repo; терминальные задачи (done/cancelled) исключены." + module: tests/test_metrics.py + expected: PASS + + - id: TC-03 + type: unit + description: "Раздел queue: counts (queued/running/failed), max_concurrency, сырьё ретраев и breaker-снимок (state/consecutive_transient/pause_remaining_s) присутствуют." + module: tests/test_metrics.py + expected: PASS + + - id: TC-04 + type: unit + description: "Раздел agents: по running-job отдаются agent, run_id, job_id, pid, runtime_s и поле CPU-liveness сырья (cpu_ticks или эквивалент)." + module: tests/test_metrics.py + expected: PASS + + - id: TC-05 + type: unit + description: "agent-liveness never-raise: при pid=None или отсутствующем /proc/ CPU-поле = null, остальные поля агента и весь ответ целы (без исключения)." + module: tests/test_metrics.py + expected: PASS + + - id: TC-06 + type: unit + description: "Раздел cost.aggregate: суммы cost_usd/input_tokens/output_tokens/cache_read_tokens/cache_creation_tokens из agent_runs; пустая таблица -> нули, не ошибка." + module: tests/test_metrics.py + expected: PASS + + - id: TC-07 + type: unit + description: "Never-raise по разделу: если источник раздела (напр. job_status_counts/worker.status) бросает, раздел получает null/дефолт, build_metrics() не пробрасывает исключение." + module: tests/test_metrics.py + expected: PASS + + - id: TC-08 + type: integration + description: "GET /metrics через ASGI/обработчик возвращает 200 и валидный JSON со всеми разделами на засеянной БД (задача + running-job + agent_run)." + module: tests/test_metrics.py + expected: PASS + + - id: TC-09 + type: integration + description: "Read-only: снимок всех таблиц БД (tasks/jobs/agent_runs) до и после вызова /metrics идентичен; повторный вызов не меняет состояние." + module: tests/test_metrics.py + expected: PASS + + - id: TC-10 + type: integration + description: "Аддитивность: GET /health, /status, /queue сохраняют прежний контракт (ключи на месте) при наличии /metrics; существующие тесты эндпоинтов зелёные." + module: tests/test_metrics.py + expected: PASS + + - id: TC-11 + type: unit + description: "Пустое состояние: при отсутствии активных задач/running-jobs/agent_runs ответ валиден — stages=[], agents=[], cost.aggregate=нули, queue.counts с нулями; 200/без исключений." + module: tests/test_metrics.py + expected: PASS