analyst(ET): auto-commit from analyst run_id=541
All checks were successful
CI / test (push) Successful in 43s

This commit is contained in:
2026-06-10 01:37:23 +03:00
parent cfd1685696
commit 7ecf95db9d
4 changed files with 527 additions and 0 deletions

View File

@@ -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/<pid>/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/<pid>/stat` (процесс умер между выборкой job и чтением proc) → закрывается
NFR-2 (`null`, не ошибка).
- Расхождение контракта `/metrics` и ожиданий sidecar (F1b) → закрывается BR-7 (контракт в одном
репо, документирован) + `schema_version` (NFR-6).
- Соблазн «протащить» в `/metrics` логику алертинга → закрывается scope-границей (вне объёма) и
NFR-1.
Детальная оценка технических рисков — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -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/<pid>/stat` (поля 1415), плюс
`clk_tck` (`os.sysconf("SC_CLK_TCK")`), чтобы sidecar посчитал CPU-дельту между опросами;
- вариант B: орк сам не считает дельту (он опрашивается стейтлесс sidecar'ом) — отдаёт только
сырые тики + временную метку выборки.
- `model`, `effort``agent_runs.model`/`effort` (контекст стоимости).
Инвариант (NFR-2): `pid is None` ИЛИ `/proc/<pid>` отсутствует/гонка (процесс умер) →
`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": "<ISO-8601 / datetime('now')>",
"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»).

View File

@@ -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/<pid>/stat`, нет сканирования git/файлового дерева;
данные берутся из существующих helper'ов БД и `worker`-снапшота; на типовом объёме ответ
формируется без заметной задержки.
- **FAIL:** эндпоинт делает сетевой запрос, запускает агента/тяжёлый процесс, сканирует worktree/git
или выполняет дорогие агрегаты, заметно тормозящие ответ.
---
## AC-4 — Never-raise (ошибка поля → `null`, эндпоинт не падает)
**Условие:** любая ошибка сбора отдельного поля/раздела не роняет эндпоинт.
- **PASS:** при недоступном источнике (например, `worker` не инициализирован, `pid` уже мёртв,
`/proc/<pid>` отсутствует, пустые таблицы) соответствующее поле получает `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/<pid>/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 |

View File

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