diff --git a/.env.example b/.env.example index b422fbd..4d479f1 100644 --- a/.env.example +++ b/.env.example @@ -394,6 +394,12 @@ ORCH_COVERAGE_EPSILON=0.5 ORCH_COVERAGE_TOOL_FAIL_CLOSED=false ORCH_COVERAGE_RUN_TIMEOUT_S=900 +# ORCH-099 (FND/F1a): operator off-switch for the read-only GET /metrics endpoint +# (raw-signal snapshot for the F1b sidecar). Default true -> available out of the +# box. false -> /metrics returns a minimal parsable body {"schema_version":1, +# "enabled":false} (200, not 404). The endpoint is inert / read-only anyway. +ORCH_METRICS_ENABLED=true + # ORCH-021: post-deploy production monitoring + degradation reaction. After the # terminal deploy->done transition for an applicable repo, a reserved-agent job # `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a diff --git a/.task-dev.md b/.task-dev.md index 32c813d..1964470 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-093 +Work item: ORCH-099 Repo: orchestrator -Branch: feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p +Branch: feature/ORCH-099-fnd-f1a-metrics-agent-liveness Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d218e87..4ceaa28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Лёгкий read-only `GET /metrics` — машинное «сырьё» о самом орке для sidecar F1b** (ORCH-099, FND/F1a, `feat`): добавлен версионируемый JSON-эндпоинт `GET /metrics`, отдающий снимок внутреннего состояния орка для будущего отдельного sidecar-наблюдателя F1b (`watchdog/`) — наблюдатель отделён от наблюдаемого (BRD §1): орк отдаёт ТОЛЬКО факты, которые знает лишь он сам; пороги/алерты/история/Telegram — на стороне F1b. **Аддитивно, строго read-only, never-raise:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — **не тронуты**; `/health`/`/status`/`/queue` — байт-в-байт прежние. ADR: `docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md`, сквозной `docs/architecture/adr/adr-0030-metrics-endpoint.md`. + - **Leaf-сборщик + тонкий эндпоинт (D1):** новый `src/metrics.py` (`build_metrics() -> dict`, never-raise по разделам, паттерн `serial_gate.snapshot()`) собирает конверт по-раздельно (каждый раздел в своём `try/except` → безопасный дефолт `null`/`[]`/`{}` + WARNING); эндпоинт `@app.get("/metrics")` в `src/main.py` — тонкая обёртка, возвращает результат как есть (стиль `GET /queue`). Тестируемость без ASGI: разделы проверяются прямым вызовом `build_metrics()`. + - **Конверт + контракт `schema_version` (D2):** `schema_version` (стартует с `1`), `generated_at` (UTC ISO-8601, часовой домен орка → дельты CPU иммунны к skew орк↔sidecar, TR-3), `clk_tck` (`os.sysconf("SC_CLK_TCK")`, базис тиков). Политика: аддитивные изменения **НЕ бампят** версию (sidecar обязан игнорировать незнакомые ключи) — бамп только при ломающем (rename/remove/retype). + - **Разделы сырья (D3–D7):** `stages` — незавершённые задачи (`stage NOT IN ('done','cancelled')`, ORCH-090) с `work_item`/`stage`/`age_in_stage_s`/`repo` (источник `db.get_active_tasks_for_reconcile()` + фильтр терминалов на потребителе, helper-инвариант ORCH-053/086 не тронут). `queue` — `db.job_status_counts()` (+`cancelled`-ключ дефолтом), глубина, сырьё ретраев (`db.queue_retry_stats()`: attempts/transient/в-backoff), `worker.breaker.snapshot()`, `max_concurrency`. `agents` (liveness) — по running-job (новый read-only `db.get_running_agents()`, dedicated SELECT, НЕ расширение hot-path `get_running_jobs()`): `agent`/`run_id`/`job_id`/`pid`/`runtime_s` (= `running_age_s` от `jobs.started_at`, D6)/`model`/`effort` + **CPU-сырьё** `cpu_ticks` (utime+stime из `/proc//stat`, поля 14+15; орк дельту не считает — stateless, арбитр sidecar). `cost` — `running` (по running-job, `null` до завершения = честное сырьё) + `aggregate` (новый `db.agent_cost_totals()`, `COALESCE(SUM(...),0)` по `agent_runs`). + - **Never-raise сырьё для liveness (FR-6/NFR-2):** `metrics._read_cpu_ticks(pid)` — `pid is None` / нет `/proc/` / мёртвый процесс / не-Linux → `cpu_ticks: null` у этого агента, прочие поля и весь эндпоинт целы (НЕ raise). Недоступный `worker` → `breaker: null`/`max_concurrency: null`, не 500. Пустые таблицы → `stages=[]`/`agents=[]`/`cost.aggregate=нули`. + - **Kill-switch (D8):** `src/config.py` `metrics_endpoint_enabled: bool = True` (env `ORCH_METRICS_ENABLED` через явный `validation_alias` — документированное имя контракта реально управляет флагом). `False` → `200` с минимальным телом `{"schema_version":1,"enabled":false}` (НЕ 404 — контракт остаётся парсимым). Дефолт `True` → нулевая регрессия (эндпоинт доступен из коробки). + - **Контракт задокументирован (AC-7):** формат `/metrics` зафиксирован в `docs/architecture/README.md` (раздел «Сырьё-эндпоинт `/metrics`» + строка в таблице API) как стабильный контракт для F1b. Тесты: `tests/test_metrics.py` (TC-01…TC-11: конверт/4 раздела, исключение терминалов, queue-поля, liveness-сырьё + cpu_ticks на живом pid, never-raise на `pid=None`/мёртвом pid/бросающем источнике/недоступном breaker, cost-агрегат + пустая таблица, эндпоинт через handler, read-only снимок БД до/после, аддитивность `/health`//status//queue, пустое состояние, kill-switch). Полный регресс `tests/ -q` зелёный (1480 → +14). Откат: `ORCH_METRICS_ENABLED=false` (мгновенный) или удаление модуля/эндпоинта/helper'ов (без следов в БД/схеме). - **Детерминированный гейт покрытия тестами — защита от тихой деградации coverage перед merge в `main`** (ORCH-027, `feat`): существующие тестовые гейты (`check_ci_green`, `check_tests_passed`, merge-gate re-test) судят только по **факту** прохождения, не по **полноте** — ни один не замечает «300 строк кода, 0 тестов», и при пакетном автономном прогоне (ORCH-088) покрытие монотонно деградирует. Введён детерминированный (без LLM) под-гейт ребра `deploy-staging → deploy` по образцу security-гейта (ORCH-022): leaf `src/coverage_gate.py` (never-raise) + тонкая обёртка `check_coverage_gate` в `QG_CHECKS` + врезка `_handle_coverage_gate` в `advance_stage`. **Аддитивно:** `STAGE_TRANSITIONS` / семантика существующих `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) — байт-в-байт прежние; новая БД-таблица аддитивна (NFR-5/AC-8). См. `docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md`, сквозной `docs/architecture/adr/adr-0029-coverage-gate.md`. - **Точка/порядок (D1, AC-2):** под-гейт исполняется **ПОСЛЕ merge-gate** (покрытие меряется на догнанном `auto_rebase_onto_main` HEAD — ровно том коде, что landed в `main`) и **ДО image-freshness** (фейл до дорогого docker-rebuild). FAIL → штатный откат на `development` (+ инкремент developer-retry, cap `MAX_DEVELOPER_RETRIES`) **и освобождение merge-lease** (merge-gate держал его на своём PASS — зеркало image-freshness rollback, TR-2). `STAGE_TRANSITIONS` не меняется (под-гейт, как security/merge/image-freshness). - **Измерение (D2, FR-1/AC-1):** `python -m pytest tests/ --cov=src --cov-report=json` в изолированном per-branch worktree (`ensure_worktree`, прецедент `check_tests_local`); метрика — `totals.percent_covered` (line coverage `src/`). Измеритель инкапсулирован за `measure_coverage(repo, branch) -> float | None` (стек-расширяемость BR-6: jest/jacoco — новая ветка `measure_*`, без переписывания ядра). Тайм-аут `coverage_run_timeout_s`. Новая pip-зависимость `pytest-cov==5.0.0` (offline на момент замера). diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ae4a622..3638591 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -18,6 +18,59 @@ - **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. **ORCH-091 (индикация-only):** три корректности рендера — (1) `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (добавлены `deploy-staging`→«Deploying (staging)», `cancelled`→«Cancelled»; полнота гарантируется тестом по `stages.STAGE_TRANSITIONS`, не статичным списком — NFR-3), runtime-фолбэк для неизвестной стадии стал нейтральным (капитализированное имя) вместо «To Analyse»; (2) при откате конвейера `✅`-строки стадий ПОЗЖЕ текущей позиции (позиция — из порядка `STAGE_TRANSITIONS`, с нормализацией `deploy-staging→deploy` только в гейте подавления; `is_active_stage` не тронут) больше не рисуются; (3) строка стадии суммирует ВСЕ `agent_runs` агента (Σ cost/токены/время теми же формулами, что блок тоталов) → строгая сходимость с `SUM(agent_runs)`. Только `src/notifications.py` + тесты; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/транспорт — не тронуты. Контракт всего компонента — never raises; карточка всегда silent. **ORCH-095 (HTML-безопасность данных):** текст карточки шлётся с `parse_mode=HTML`; каждый **data**-слот (длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл, модель/эффорт, токены/стоимость) экранируется `html.escape` ровно один раз на границе рендера, **markup**-слоты (`num_html`/`link_for`/`_done_link`/`esc_title`) — нет (двойное экранирование запрещено). Устранён класс «неэкранированные данные в HTML» (литерал `<1м` от `_fmt_minutes` → Telegram `400 can't parse entities` → застывшая карточка, инцидент ORCH-093); `_fmt_minutes` по-прежнему даёт `<1м` (escape рендерит визуально идентично). Застрявшая карточка в окне авто-восстанавливается следующим рендером; `edit_telegram`/`update_task_tracker`/леджер сирот не тронуты. Детали — [internals.md](internals.md) §7, [ADR-087](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md), [ORCH-091 ADR-001](../work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md) и [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md). - **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту. - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость). +- **Metrics endpoint** (`src/metrics.py` + `GET /metrics`, ORCH-099 — [adr-0030](adr/adr-0030-metrics-endpoint.md)) — лёгкий **read-only** leaf-сборщик (`build_metrics() -> dict`, never-raise по разделам, паттерн `serial_gate.snapshot()`) + тонкий эндпоинт (стиль `GET /queue`). Отдаёт JSON-«сырьё» о самом орке (стадии задач / очередь jobs / agent-liveness / стоимость-токены) как **стабильный машинный контракт для sidecar F1b** (`watchdog/`, отдельная задача — наблюдатель отделён от наблюдаемого). Только чтение существующих `tasks`/`jobs`/`agent_runs` + in-memory-снапшотов (`worker.breaker`); два read-only helper'а в `db.py` (`get_running_agents`/`agent_cost_totals`). Логику мониторинга (пороги/алерты/история/Telegram) НЕ несёт — это F1b. Контракт ниже (§ «Сырьё-эндпоинт `/metrics`»). Kill-switch `metrics_endpoint_enabled` (дефолт `True`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. + +## Сырьё-эндпоинт `/metrics` для sidecar (ORCH-099 — design) + +`GET /metrics` (read-only, never-raise) отдаёт лёгкий JSON-снимок внутреннего «сырья» орка — +**стабильный контракт** для будущего sidecar-наблюдателя **F1b** (`watchdog/`). Орк отдаёт ТОЛЬКО +факты, которые знает лишь он сам; арбитраж «застрял/завис», пороги, алерты, история — на стороне +F1b (рамка C-1: наблюдатель отделён от наблюдаемого). Источник логики — `src/metrics.py` +(`build_metrics()`), эндпоинт — тонкая обёртка в `src/main.py`. + +**Конверт ответа:** +```json +{ + "schema_version": 1, + "generated_at": "", + "clk_tck": 100, + "stages": [ { "work_item", "stage", "age_in_stage_s", "repo", "task_id?" } ], + "queue": { "counts": {...}, "depth", "retries": {...}, "breaker": {...}|null, "max_concurrency" }, + "agents": [ { "agent", "run_id", "job_id", "pid", "runtime_s", "model", "effort", "cpu_ticks"|null } ], + "cost": { "running": [...], "aggregate": { "cost_usd", "input_tokens", "output_tokens", + "cache_read_tokens", "cache_creation_tokens" } } +} +``` + +- **`stages`** — незавершённые задачи (`stage NOT IN ('done','cancelled')`, ORCH-090): + `work_item`/`stage`/`age_in_stage_s` (секунды с `tasks.updated_at`)/`repo`. Источник — + `db.get_active_tasks_for_reconcile()` + фильтр терминалов на слое metrics. +- **`queue`** — `db.job_status_counts()` (+`cancelled`), глубина, сырьё ретраев + (`attempts`/`max_attempts`/`transient_attempts`/в-backoff), `worker.breaker.snapshot()` + (`state`/`consecutive_transient`/`pause_remaining_s`), `max_concurrency`. +- **`agents` (liveness)** — по running-job (`db.get_running_agents()`): + `agent`/`run_id`/`job_id`/`pid`/`runtime_s` (= `running_age_s` от `jobs.started_at`)/`model`/ + `effort` + **CPU-сырьё** `cpu_ticks` (utime+stime из `/proc//stat`, поля 14+15). Орк дельту + **не считает** (stateless) — sidecar считает CPU-долю по двум опросам через `cpu_ticks`, + `clk_tck` и `generated_at`. `pid is None`/мёртвый/нет `/proc`/не-Linux → `cpu_ticks: null`. +- **`cost`** — `running` (по running-job, часто `null` до завершения: токены парсятся из CLI-JSON в + `launcher._monitor_agent` по окончании — `null` ≠ ноль) + `aggregate` (`db.agent_cost_totals()`, + `COALESCE(SUM(...),0)` по `agent_runs`). + +**Контракт версии (NFR-6):** `schema_version` стартует с `1`. Аддитивные изменения (новое +поле/раздел) **НЕ бампят** версию — sidecar **обязан игнорировать незнакомые ключи и толерировать +отсутствие опциональных**; бамп — **только** при ломающем (rename/remove/retype существующего поля). + +**Гарантии:** строго read-only (ни одного `INSERT/UPDATE/DELETE/CREATE/ALTER`, без +процессов/сети/сканов git); never-raise по разделам (ошибка раздела → `null`/`[]`/`{}` + WARNING, +эндпоинт всегда `200`); `/health`/`/status`/`/queue` — байт-в-байт прежние; `STAGE_TRANSITIONS`/ +`QG_CHECKS`/`check_*`/machine-verdict-ключи/схема БД — не тронуты. Kill-switch +`metrics_endpoint_enabled` (env `ORCH_METRICS_ENABLED`, дефолт `True`; `False` → `200` с +`{"schema_version":1,"enabled":false}`). Self-hosting-безопасно: физически не влияет на конвейер. +Прямой потребитель контракта — **F1b** (заблокирована этой задачей). + +Подробнее: [adr-0030](adr/adr-0030-metrics-endpoint.md), детально — +`docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md`. ## Конвейер и Quality Gates @@ -956,6 +1009,7 @@ Monitoring after Deploy → Done | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != done) | | GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + последние jobs | +| GET | `/metrics` | ORCH-099 (FND/F1a): read-only машинное «сырьё» для sidecar F1b — конверт `schema_version`/`generated_at`/`clk_tck` + разделы `stages`/`queue`/`agents` (liveness: pid/runtime/cpu_ticks)/`cost`. never-raise по разделам; kill-switch `ORCH_METRICS_ENABLED` (дефолт `True`). Контракт — см. раздел «Сырьё-эндпоинт `/metrics`» | | POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` | | POST | `/webhook/plane` | Plane webhook | | POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) | diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 153dd54..55c43eb 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -31,11 +31,16 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0023 | Обзорная ось reviewer + закрытие эпика 52 | accepted | 2026-06-09 | ORCH-079 | | adr-0024 | Disk-watchdog — heartbeat-сигнал заполнения хост-ФС | proposed | 2026-06-09 | ORCH-063 | | adr-0025 | Build-cache-pruner — авто-prune docker build cache на хосте | proposed | 2026-06-09 | ORCH-062 | +| adr-0026 | STOP / отмена задачи — системный терминал `cancelled` | proposed | 2026-06-09 | ORCH-090 | +| adr-0027 | Merge-актор — ретрай транзиентных ошибок Gitea + гард «ветка уже в `main`» | proposed | 2026-06-09 | ORCH-093 | +| adr-0028 | Terminal-window-aware гард deploy-фазовых статусов Plane | proposed | 2026-06-09 | ORCH-094 | +| adr-0029 | Гейт покрытия тестами — edge sub-gate + ratchet-базовая линия | proposed | 2026-06-10 | ORCH-027 | +| adr-0030 | Лёгкий read-only `/metrics` — сырьё о самом орке для sidecar (F1b) | proposed | 2026-06-10 | ORCH-099 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0020`). +> свободный номер (текущий максимум — `0030`). > adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»). > adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082). > adr-0020 реализует машинный слой к adr-0019 (ORCH-52b→52c). diff --git a/docs/architecture/adr/adr-0030-metrics-endpoint.md b/docs/architecture/adr/adr-0030-metrics-endpoint.md new file mode 100644 index 0000000..4938b04 --- /dev/null +++ b/docs/architecture/adr/adr-0030-metrics-endpoint.md @@ -0,0 +1,88 @@ +--- +work_item: ORCH-099 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# adr-0030: Лёгкий read-only `/metrics` — сырьё о самом орке для sidecar (F1b) + +- **Статус:** proposed +- **Дата:** 2026-06-10 +- **Задача:** ORCH-099 (FND/F1a) +- **Детальный ADR:** `docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md` + +## Контекст +Эпик автономного саморазвития, домен 0 «Фундамент». Рамка наблюдаемости (заказчик): **наблюдатель +отделён от наблюдаемого** — мозг мониторинга (пороги/алерты/история/Telegram) живёт в отдельном +sidecar-контейнере **F1b** (`watchdog/`), а орк отдаёт **только сырьё**, которое знает лишь он сам. +Сегодня такого источника нет: `/health` = `{"status":"ok"}`, `/status` = активные задачи, `/queue` — +«человеческий» снимок, перемешанный с конфигом демонов. Нет стабильного машинного контракта для +детекта застрявшей стадии / зависшего агента / деградации очереди / всплеска стоимости. F1b +заблокирована этой задачей. Self-hosting: прод общий с enduro-trails ⇒ эндпоинт обязан быть строго +read-only и never-raise. + +## Решение +Новый **leaf-модуль** `src/metrics.py` (`build_metrics() -> dict`, чистый, never-raise по разделам — +паттерн `serial_gate.snapshot()`) + тонкий эндпоинт `@app.get("/metrics")` в `src/main.py` (стиль +`GET /queue`). Только чтение существующих таблиц (`tasks`/`jobs`/`agent_runs`) и in-memory-снапшотов ++ два read-only helper'а в `src/db.py`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict- +ключи/схема БД — **не трогаются**. + +- **Конверт + контракт версии:** `schema_version` (старт `1`), `generated_at` (UTC ISO-8601 — + момент снимка, домен часов орка), `clk_tck` (`os.sysconf("SC_CLK_TCK")`), разделы + `stages`/`queue`/`agents`/`cost`. **Политика версии:** аддитивные изменения НЕ бампят (sidecar + обязан игнорировать незнакомые ключи и толерировать отсутствие опциональных); бамп — только при + ломающем (rename/remove/retype). Forward-compatible контракт для F1b. +- **`stages`** — `db.get_active_tasks_for_reconcile()` + фильтр `stage NOT IN ('done','cancelled')` + на слое metrics (helper намеренно отдаёт `cancelled` для ORCH-086 — не трогаем его инвариант); + поля `work_item`/`stage`/`age_in_stage_s`/`repo`. +- **`queue`** — `db.job_status_counts()` (+`cancelled`), глубина, сырьё ретраев + (`attempts`/`max_attempts`/`transient_attempts`/в-backoff), `worker.breaker.snapshot()`, + `max_concurrency`. Недоступный worker → `breaker: null`, не 500. +- **`agents` (liveness)** — новый dedicated read-only helper `db.get_running_agents()` (НЕ расширение + hot-path `get_running_jobs()` reaper'а, ORCH-065): `agent`/`run_id`/`job_id`/`pid`/`runtime_s` + (= `running_age_s` от `jobs.started_at`)/`model`/`effort`. CPU-сырьё — **вариант A**: орк читает + `/proc//stat` (поля 14+15, utime+stime) → `cpu_ticks`; **дельту не считает** — арбитр + «жив/завис» это sidecar (stateless-эмиссия). `pid is None`/мёртвый/нет `/proc`/не-Linux → + `cpu_ticks: null`, не ошибка. +- **`cost`** — `running` (по running-job, часто `null` до завершения — честное сырьё, `null` ≠ ноль) + + `aggregate` (новый helper `db.agent_cost_totals()`, `COALESCE(SUM(...),0)` по + `cost_usd`/`input_tokens`/`output_tokens`/`cache_read_tokens`/`cache_creation_tokens`). +- **Kill-switch** `metrics_endpoint_enabled` (env `ORCH_METRICS_ENABLED`, дефолт `True`): при `False` + → `200` с `{"schema_version":1,"enabled":false}` (контракт остаётся парсимым). Операторский + off-switch на общем инстансе. +- **Never-raise:** каждый раздел — свой `try/except` + `logger.warning` + дефолт (`null`/`[]`/`{}`); + `build_metrics()` никогда не пробрасывает. Read-only: ни одного `INSERT/UPDATE/DELETE/CREATE/ALTER`. + +## Альтернативы +- **Расширить `/queue`** — отклонено: ломает байт-в-байт контракт (BR-6) + смешивает сырьё с + человеческим снимком. +- **Prometheus/OpenMetrics** — отклонено: заказчик задал тонкий кастомный sidecar (не Prometheus), + контракт — JSON. +- **Орк считает CPU-дельту сам** — отклонено: требует состояния; stateful-арбитр это sidecar (C-1). +- **Расширить SELECT `get_running_jobs()`** — отклонено: перенос инварианта hot-path reaper'а; + изолируем dedicated helper. +- **Push в sidecar** — отклонено: нарушает разделение C-1; зависший орк ⇒ pull падает = сам сигнал. + +## Последствия +- F1b разблокирована стабильным машинным контрактом; домен наблюдаемости стартует. +- Строго read-only + never-raise ⇒ near-zero риск для общего прод-конвейера (enduro-trails); + `/health`/`/status`/`/queue` байт-в-байт; гейты/схема/machine-verdict-ключи не тронуты (NFR-5). +- `schema_version` + аддитивно-толерантная политика ⇒ расширения не ломают F1b. +- Плата: новая поверхность совместимости `/metrics`↔F1b (митигейшн — единый репо контракта + версия); + CPU-liveness Linux-специфичен (`/proc`; не-Linux → `null`). Топология/схема не меняются (sidecar и + его сетевая достижимость — объём F1b). +- Новый компонент + публичный контракт → `arch:major-change` (хоть и аддитивно/read-only/обратимо); + прод-деплой строго через staging-гейт (8501), без рестарта прод-контейнера. +- **Откат:** `metrics_endpoint_enabled=False` (мгновенный) или удаление модуля/эндпоинта/helper'ов — + без следов в БД/схеме. + +## Связи +adr-0002 (job-queue/circuit-breaker — источник `queue`-сырья), adr-0011 (job-reaper — +`get_running_jobs`/pid/liveness-семантика, изоляция hot-path), adr-0026 (терминал `{done,cancelled}` +— фильтр `stages`), adr-0017 (serial_gate — паттерн leaf `snapshot()`/never-raise), adr-0020 +(frontmatter-контракт — стиль версионируемого контракта). Прямой потребитель — **F1b** (sidecar +`watchdog/`, отдельная задача). diff --git a/docs/work-items/ORCH-099/00-business-request.md b/docs/work-items/ORCH-099/00-business-request.md new file mode 100644 index 0000000..544f68b --- /dev/null +++ b/docs/work-items/ORCH-099/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: FND/F1a: лёгкий /metrics в орке — отдать сырьё (стадии/очередь/agent-liveness/cost) + +Work Item ID: ORCH-099 + +## Description + +TBD 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 diff --git a/docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md b/docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md new file mode 100644 index 0000000..d75a008 --- /dev/null +++ b/docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md @@ -0,0 +1,249 @@ +--- +work_item: ORCH-099 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# ADR-001: Лёгкий read-only `/metrics` — сырьё о самом орке для sidecar (F1b) + +Work Item: **ORCH-099** — FND/F1a: лёгкий `/metrics` в орке (отдать сырьё) +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0030-metrics-endpoint.md`** (решение +кросс-каттинговое — новый компонент наблюдаемости + новый публичный HTTP-контракт для будущего +sidecar F1b). + +## Статус +Proposed + +## Контекст + +F1a — фундаментный кирпич домена 0 «Фундамент» эпика автономного саморазвития. Рамка наблюдаемости +зафиксирована заказчиком (BRD §1): **наблюдатель отделён от наблюдаемого** — мониторинг (пороги, +алерты, история, свой Telegram) живёт в отдельном sidecar-контейнере **F1b** (`watchdog/`), а орк +отдаёт **только сырьё**, которое знает лишь он сам. F1a поставляет источник этого сырья и **ничего +больше**. + +Факты, сверенные с кодом: +- `GET /health` (`src/main.py:147`) → `{"status":"ok", ...}`; `GET /status` (`:152`) → список + активных задач; `GET /queue` (`:163`) — богатый, но «человеческий» снимок, перемешанный с + конфигом демонов (reconciler/reaper/post_deploy/disk_monitor/…). Ни один не даёт **стабильного + машинного контракта** для детекта: застрявшая стадия, зависший агент, деградация очереди, всплеск + стоимости. +- Все нужные данные уже в БД и in-memory: `db.get_active_tasks_for_reconcile()` (`src/db.py:388` — + `stage != 'done'` + `age_s` в SQL), `db.get_running_jobs()` (`:1103` — `SELECT j.*` + `running_age_s`, + LEFT JOIN `agent_runs` на `run_id`), `db.job_status_counts()` (`:1187`), + `queue_worker.worker.status()`/`CircuitBreaker.snapshot()` (`src/queue_worker.py:242`/`:113` — breaker + in-memory). `pid`/`run_id`/`job_id` — колонки `jobs` (ORCH-065, `:83`); `model`/`effort`/`cost_usd`/ + `*_tokens` — колонки `agent_runs` (`:97`–`:106`). Терминальное множество — `{done, cancelled}` + (ORCH-090, adr-0026). +- Self-hosting: прод-контейнер общий с enduro-trails. Эндпоинт обязан быть строго **read-only** и + **never-raise** — не ронять и не тормозить прод ни при каких входных данных. + +«Как есть» не годится: добавлять поля в `/queue` сломало бы его контракт (BR-6) и смешало бы сырьё с +человеческим снимком; в коде sidecar'а нет ни одной стабильной точки опроса. Нужен отдельный, +версионируемый, машинный контракт. + +## Решение + +### Сводка + +Новый **leaf-модуль** `src/metrics.py` с чистой never-raise функцией-сборщиком +`build_metrics() -> dict` (по образцу `serial_gate.snapshot()`/`task_deps.snapshot()`/ +`cancel.snapshot()`) + тонкий эндпоинт `@app.get("/metrics")` в `src/main.py` (обёртка над +сборщиком, в стиле `GET /queue`). Сборщик собирает четыре раздела (`stages`/`queue`/`agents`/`cost`) +в версионируемом конверте, **каждый раздел — в своём `try/except`** с безопасным дефолтом. Только +чтение существующих таблиц (`tasks`/`jobs`/`agent_runs`) и существующих in-memory-снапшотов + два +read-only helper'а в `src/db.py`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict-ключи/ +схема БД — **не трогаются**. + +### D1 — Новый leaf-модуль + тонкий эндпоинт (изоляция, never-raise по разделам) + +Логика сборки — в `src/metrics.py`, не в `main.py` (тестируемость без ASGI, паттерн `*.snapshot()`). +`build_metrics()` строит конверт по-раздельно; каждый раздел обёрнут в `try/except Exception`, в +`except` → `logger.warning(...)` + безопасный дефолт (`null` для скаляра/объекта, `[]` для списка). +Функция **никогда** не пробрасывает исключение (FR-6, NFR-2, AC-4). Эндпоинт `/metrics` — +тонкая обёртка: возвращает `build_metrics()` как есть; собственной обработки ошибок не требует +(сборщик уже never-raise). Уровень доступа — тот же, что `/queue`/`/status` (без доп. аутентификации, +FR-4). + +Привязка: FR-6, NFR-1, NFR-2, AC-4, AC-5. + +### D2 — Конверт ответа + контракт `schema_version` (BR-1, BR-6, NFR-6) + +```json +{ + "schema_version": 1, + "generated_at": "2026-06-10T12:34:56Z", + "clk_tck": 100, + "stages": [ ... ], + "queue": { ... }, + "agents": [ ... ], + "cost": { "running": [ ... ], "aggregate": { ... } } +} +``` + +- **`schema_version` стартует с `1`.** Политика инкремента (контракт для F1b, документируется в + README, BR-7): **аддитивные** изменения (новое поле/раздел) **НЕ бампят версию** — sidecar + **обязан игнорировать незнакомые ключи и толерировать отсутствие опциональных**. Версия бампится + **только при ломающем** изменении (переименование/удаление/смена типа существующего поля). Это + делает контракт forward-compatible: будущие расширения F1a не ломают уже написанный F1b (NFR-6, + TR-4). Формат-чек версии — по духу `is_valid_model` (ORCH-74): структурный, не статичный allowlist. +- **`generated_at`** — `datetime('now')` UTC, ISO-8601 (тот же часовой домен, что timestamp'ы БД и + выборка CPU-тиков). Это момент снимка: sidecar считает дельты между двумя опросами по + `(cpu_ticks, generated_at)` из ответов — **всё в часах самого орка**, поэтому расчёт иммунен к + расхождению часов орк↔sidecar (TR-3). +- **`clk_tck`** — `os.sysconf("SC_CLK_TCK")` на уровне конверта (а не на каждом агенте — значение + процесс-глобальное): базис для перевода CPU-тиков в секунды на стороне sidecar. + +Привязка: BR-1, BR-6, NFR-6, FR-5, AC-1, AC-7. + +### D3 — Раздел `stages` (BR-2, FR-1) + +Список активных задач из `db.get_active_tasks_for_reconcile()`, **с дополнительной фильтрацией +`stage NOT IN ('done','cancelled')`** на слое metrics. Обоснование: helper намеренно возвращает +`cancelled`-задачи (для skip-счётчика реконсилятора ORCH-086, см. `src/db.py:396`) — но для сырья +наблюдаемости терминальные задачи не нужны (терминальное множество `{done, cancelled}`, ORCH-090). +Не меняем helper (его инвариант принадлежит ORCH-053/086) — фильтруем на потребителе. По каждой +задаче: `work_item` (`work_item_id`), `stage`, `age_in_stage_s` (= `age_s`, целое, SQL +`strftime` против UTC-now — момент последней смены стадии), `repo` (sidecar мультипроектный), +опц. `task_id`/`created_age_s`. Пустой список — валидный ответ (AC, TC-11). + +Привязка: BR-2, FR-1, AC-1, TC-02, TC-11. + +### D4 — Раздел `queue` (BR-3, FR-2) + +- `counts` — `db.job_status_counts()` (`queued`/`running`/`done`/`failed`); добавить `cancelled` + (ORCH-090 терминал) — helper уже агрегирует `GROUP BY status`, нужно лишь не терять ключ. +- `depth` — глубина очереди = число `queued`-jobs (можно `counts.queued`); опц. «доступные сейчас» + с учётом `available_at <= now`. +- `retries` — агрегат по незавершённым jobs: `attempts` vs `max_attempts`, `transient_attempts`, и + как минимум «сколько jobs в backoff» (`available_at > now`). Источник — read-only SELECT-агрегат + (новый helper или агрегация по `recent_jobs`/прямой SELECT; решение реализации за developer'ом в + рамках read-only). +- `breaker` — `worker.breaker.snapshot()` (`state`/`consecutive_transient`/`pause_remaining_s`). +- `max_concurrency` — `worker.max_concurrency`; опц. `poll_interval`. + +Инвариант (NFR-2): недоступный `worker` (не инициализирован в тесте) → `breaker: null` и/или +`max_concurrency: null`, **не 500** (own `try/except` вокруг in-memory доступа). + +Привязка: BR-3, FR-2, AC-1, TC-03, TC-07. + +### D5 — Раздел `agents` (agent-liveness) — источник данных и CPU-сырьё (BR-4, FR-3) + +**Источник данных — новый dedicated read-only helper `db.get_running_agents()`, НЕ расширение +`get_running_jobs()`.** Причина: `get_running_jobs()` — hot-path запрос job-reaper'а (ORCH-065, +`src/db.py:1103`); расширять его SELECT под нужды наблюдаемости — перенос инварианта чужого +компонента. Новый helper — изолированный `SELECT j.id, j.run_id, j.pid, j.agent, j.started_at, +running_age_s, r.model, r.effort FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id WHERE +j.status='running'` (LEFT JOIN сохраняет job без `agent_runs`-строки). По каждому running-job: +`agent`, `run_id`, `job_id`, `pid` (может быть `null`), `runtime_s`, `model`, `effort`, `cpu_ticks`. + +**CPU-сырьё — вариант A (орк читает `/proc`, остаётся stateless).** Орк эмитит сырые тики, дельту +**не считает** — арбитр liveness это sidecar (BRD-допущение C-1). Чистый never-raise helper в +`src/metrics.py`: + +``` +_read_cpu_ticks(pid) -> int | None + # читает /proc//stat, поля 14 (utime) + 15 (stime), возвращает их сумму (в тиках); + # pid is None / нет /proc/ / гонка (процесс умер) / не-Linux -> None (НЕ raise) +``` + +`clk_tck` (D2) — на уровне конверта. sidecar между двумя опросами считает +`cpu_busy = (ticks₂ − ticks₁) / clk_tck`, делит на `(generated_at₂ − generated_at₁)` → доля CPU; +малая доля при растущем `runtime_s` ⇒ кандидат на «завис». Парсинг `/proc//stat` устойчив к +пробелам в `comm`: брать поля **после** `') '` (закрывающая скобка имени) — канон чтения proc-stat. + +Инвариант (NFR-2, AC-6, TC-05): `pid is None` ИЛИ мёртвый/отсутствующий `/proc/` → `cpu_ticks: +null` у этого агента; прочие поля и весь эндпоинт целы. + +Привязка: BR-4, FR-3, AC-6, TC-04, TC-05. + +### D6 — `runtime_s` — базис `jobs.started_at` (FR-3) + +`runtime_s = running_age_s` (секунды с `jobs.started_at`, считается в SQL в `get_running_agents`), +**не** `agent_runs.started_at`. Обоснование: `jobs.started_at` — якорь жизненного цикла процесса, +рядом с которым застамплен `pid` (ORCH-065); это тот же базис, что использует reaper для +backstop-liveness. Значения почти совпадают, но `jobs` — авторитетный процесс-якорь, а +`agent_runs`-строки может не быть (LEFT JOIN). Консистентность с reaper > микроточность. + +Привязка: FR-3, AC-6. + +### D7 — Раздел `cost` (BR-5, FR-4) + +- `running` — по каждому running-job текущие значения из `agent_runs`, если уже застамплены. Часто + `null` до завершения: токены/`cost_usd` парсятся из CLI-JSON в `launcher._monitor_agent` по + окончании. **`null` для незавершённых — честное сырьё** (документируется: `null` ≠ ноль, TR-5). +- `aggregate` — новый read-only helper `db.agent_cost_totals()`: чистый + `SELECT COALESCE(SUM(cost_usd),0), COALESCE(SUM(input_tokens),0), … FROM agent_runs` по + `cost_usd`/`input_tokens`/`output_tokens`/`cache_read_tokens`/`cache_creation_tokens`. Пустая + таблица → нули, не ошибка (TC-06, TC-11). Опц. срез (всего + по `repo` через джойн `tasks`) — + расширяемо без бампа версии (D2). + +Привязка: BR-5, FR-4, AC-1, TC-06, TC-11. + +### D8 — Kill-switch `metrics_endpoint_enabled` (default `True`) + +TRZ §7 оставляет флаг на усмотрение архитектора. **Решение: добавить** конфиг-флаг +`metrics_endpoint_enabled` (env `ORCH_METRICS_ENABLED`, дефолт `True`) — по образцу snapshot-флагов +кодовой базы и из self-hosting-осторожности (операторский off-switch на общем прод-инстансе). При +`False` эндпоинт возвращает **`200` с минимальным телом** `{"schema_version": 1, "enabled": false}` +(не 404 — контракт остаётся парсимым, sidecar видит `enabled:false` и трактует это явно). Дефолт +`True` ⇒ нулевая регрессия требований BRD (эндпоинт доступен из коробки). Флаг — дешёвая страховка, +не предмет BRD; реализация инертна. + +Привязка: NFR-1, NFR-4, TRZ §7. + +## Альтернативы + +- **Расширить `/queue` вместо нового эндпоинта** — отвергнуто: сломало бы байт-в-байт контракт + `/queue` (BR-6, AC-2) и смешало бы машинное сырьё с человеческим снимком + конфигом демонов; + sidecar'у нужна узкая стабильная точка. +- **Prometheus/OpenMetrics text-формат** — отвергнуто: заказчик задал тонкий кастомный sidecar (не + Prometheus, C-3); требование — JSON-контракт под конкретный F1b. +- **Орк сам считает CPU-дельту** — отвергнуто: требует состояния между опросами; орк — пассивный + источник, stateful-арбитр это sidecar (C-1). Stateless-эмиссия сырых тиков проще и надёжнее. +- **Расширить SELECT `get_running_jobs()`** под model/effort — отвергнуто: перенос инварианта + hot-path reaper'а (ORCH-065); изолируем dedicated helper `get_running_agents()`. +- **Push метрик в sidecar** — отвергнуто: нарушает разделение C-1 (орк остаётся пассивным + источником); при зависшем орке pull-опрос падает — это **сам сигнал тревоги** для sidecar. +- **Без kill-switch** — рассматривалось (эндпоинт инертен); выбран флаг ради конвенции кодовой базы + и операторского off-switch (D8). + +## Последствия + +- **+** Появляется стабильный машинный контракт сырья — F1b (заблокированная этой задачей) + разблокирована; домен наблюдаемости может стартовать. +- **+** Строго read-only + never-raise по разделам ⇒ near-zero остаточный риск для общего + прод-конвейера (enduro-trails); физически не способен повлиять на конвейер (NFR-4). +- **+** Аддитивно и обратимо: `/health`/`/status`/`/queue` байт-в-байт; `STAGE_TRANSITIONS`/ + `QG_CHECKS`/`check_*`/schema/machine-verdict-ключи не тронуты (NFR-5). +- **+** `schema_version` + аддитивно-толерантная политика ⇒ будущие расширения не ломают F1b. +- **−** Новый публичный контракт = новая поверхность совместимости: дрейф `/metrics`↔F1b митигируется + единым репозиторием контракта (README, BR-7) + `schema_version` (D2). Издержка принимается. +- **−** CPU-liveness Linux-специфичен (`/proc`); на не-Linux `cpu_ticks: null` (деградация, не + ошибка). Прод-контейнер — Linux, допущение выполняется (BRD §6). +- **Топология/схема:** не меняются (07/08 — N/A). Sidecar-контейнер и его сетевая достижимость + `/metrics` — объём **F1b**, не этой задачи (см. README-заметку о предусловии достижимости). +- **Эскалация:** формально вводится новый компонент наблюдаемости + публичный контракт → лейбл + **`arch:major-change`** (консервативно, хотя изменение полностью аддитивно/read-only/обратимо). + Прод-деплой — строго через staging-гейт (8501), без рестарта прод-контейнера. +- **Откат:** `metrics_endpoint_enabled=False` (мгновенный) либо удаление `src/metrics.py` + эндпоинта + + helper'ов — полностью откатывает изменение без следов в БД/схеме (TRZ §7). + +## Ссылки +- BRD: `docs/work-items/ORCH-099/01-brd.md` +- TRZ: `docs/work-items/ORCH-099/02-trz.md` +- Acceptance: `docs/work-items/ORCH-099/03-acceptance-criteria.md` +- Тех-риски: `docs/work-items/ORCH-099/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0030-metrics-endpoint.md` +- Сверено по коду: `src/main.py` (`/health`/`/status`/`/queue`), `src/db.py` + (`get_active_tasks_for_reconcile`/`get_running_jobs`/`job_status_counts`, схема `agent_runs`/`jobs`), + `src/queue_worker.py` (`worker.status`/`CircuitBreaker.snapshot`), `src/serial_gate.py` + (`snapshot()` — эталон never-raise). +- Связанные ADR: adr-0002 (job-queue/breaker — источник `queue`-сырья), adr-0011 (job-reaper — + `get_running_jobs`/pid/liveness-семантика), adr-0026 (терминал `{done,cancelled}` — фильтр стадий), + adr-0017 (serial_gate — паттерн leaf `snapshot()`/never-raise), adr-0020 (frontmatter-контракт — + стиль версионируемого контракта). diff --git a/docs/work-items/ORCH-099/10-tech-risks.md b/docs/work-items/ORCH-099/10-tech-risks.md new file mode 100644 index 0000000..e4bde3b --- /dev/null +++ b/docs/work-items/ORCH-099/10-tech-risks.md @@ -0,0 +1,43 @@ +--- +work_item: ORCH-099 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-099 — FND/F1a: лёгкий `/metrics` (сырьё для sidecar) + +Work Item: **ORCH-099** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | Гонка чтения `/proc//stat`: процесс умер между выборкой running-job и чтением proc → `FileNotFoundError`/частичная строка | Сред. | Низ. | `_read_cpu_ticks` never-raise → `cpu_ticks: null` (NFR-2, FR-3, AC-6); прочие поля и эндпоинт целы. Парс proc-stat читает поля **после** `') '` (устойчивость к пробелам в `comm`). | +| TR-2 | PID-namespace mismatch: `jobs.pid` относится не к тому PID-namespace, где орк читает `/proc` | Низ. | Сред. | Агент — дочерний процесс орка (launcher `subprocess` в том же контейнере/ns), `pid` стамплется орком (ORCH-065) → `/proc/` валиден в том же ns. Несовпадение → `null` (деградация, не падение). | +| TR-3 | Расхождение часов орк↔sidecar искажает расчёт CPU-доли | Низ. | Низ. | Контракт by-design: sidecar считает дельту по `(cpu_ticks, generated_at)` из **двух ответов орка** → всё в домене часов орка, skew-иммунно (ADR D2). | +| TR-4 | Дрейф контракта `/metrics`↔ожидания F1b при будущих расширениях | Сред. | Сред. | `schema_version` (старт 1) + аддитивно-толерантная политика (sidecar игнорирует незнакомые ключи, толерирует отсутствие опциональных); контракт документирован в README в одном репо (BR-7, NFR-6). | +| TR-5 | `cost.running = null` (токены ещё не застамплены) ошибочно прочитан sidecar'ом как «ноль стоимости» | Сред. | Низ. | Документировать: `null` ≠ ноль (= «не завершён, не застамплен»); авторитет по спенду — `cost.aggregate` (ADR D7). | +| TR-6 | Контеншн на `CircuitBreaker._lock` при опросе breaker-снимка | Низ. | Низ. | `snapshot()` держит lock кратко (только чтение полей, `src/queue_worker.py:113`); раздел обёрнут own `try/except` → `breaker: null` при любой проблеме. Частота опроса sidecar — секунды, не микросекунды. | +| TR-7 | Рост стоимости `SUM`-агрегата по `agent_runs` при разрастании таблицы | Низ. | Низ. | `agent_cost_totals()` — один индексируемый full-scan `SUM`, n мал (десятки–сотни строк на текущем горизонте); точка расширения — временное окно/`repo`-срез без бампа версии (ADR D2/D7). | +| TR-8 | Соблазн «протащить» в `/metrics` логику алертинга/порогов | Низ. | Сред. | Scope-граница BRD (вне объёма) + NFR-1 (read-only) + reviewer-контроль; мозг (пороги/алерты) — строго F1b. | +| TR-9 | Незаметная мутация состояния (случайный не-read-only вызов в сборщике) роняет инвариант read-only | Низ. | Выс. | Сборщик использует только SELECT-helper'ы; AC-5/TC-09 — тест «снимок БД до/после идентичен»; reviewer сверяет дифф на отсутствие `INSERT/UPDATE/DELETE/CREATE/ALTER` и запуска процессов. | + +## Сводный вывод + +Доминирующий класс — **гонки/деградация чтения runtime-данных** (`/proc`, in-memory breaker), все +закрыты конструктивным never-raise по разделам (эталон `serial_gate.snapshot()`) → деградация в +`null`, не отказ. Контрактные риски (TR-4/TR-5) закрыты `schema_version` + документированием. +Наивысшее потенциальное влияние (TR-9, нарушение read-only) митигируется тестом «БД до/после +идентична» (TC-09) и reviewer-сверкой диффа. + +Изменение полностью аддитивно, read-only, never-raise, обратимо (kill-switch + удаление модуля). +**Остаточный риск для прод-конвейера (self-hosting, общий с enduro-trails) — near-zero:** эндпоинт +физически не способен мутировать состояние или уронить процесс (NFR-1/NFR-2/NFR-4). Эскалация в +анализ не требуется. Формальный лейбл **`arch:major-change`** проставляется консервативно (новый +компонент наблюдаемости + публичный контракт), хотя по существу изменение низкорисковое; прод-деплой +— строго через staging-гейт (8501), без рестарта прод-контейнера. diff --git a/docs/work-items/ORCH-099/12-review.md b/docs/work-items/ORCH-099/12-review.md new file mode 100644 index 0000000..a667241 --- /dev/null +++ b/docs/work-items/ORCH-099/12-review.md @@ -0,0 +1,86 @@ +--- +verdict: APPROVED +work_item: ORCH-099 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-099 +version: 1 +--- + +# Review ORCH-099 — FND/F1a: лёгкий read-only `GET /metrics` (сырьё для sidecar F1b) + +## Summary + +Реализация полностью соответствует ТЗ (`02-trz.md`), критериям приёмки (`03-acceptance-criteria.md`) +и архитектурному решению (`06-adr/ADR-001` + сквозной `adr-0030`). Добавлен аддитивный, строго +read-only, never-raise эндпоинт `GET /metrics` через leaf-модуль `src/metrics.py` (`build_metrics()`, +паттерн `serial_gate.snapshot()`) + тонкая обёртка в `src/main.py` + три read-only helper'а в +`src/db.py`. Конвейерные инварианты целы: `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / +machine-verdict ключи / схема БД — не тронуты (в диффе нет `src/stages.py`/`src/qg/`; упоминания этих +имён — только в документации/комментариях). Полный регресс `pytest tests/ -q` — **1482 passed**. +Документация обновлена в том же PR. Блокирующих findings нет. + +**Оси проверки:** + +1. **Соответствие ТЗ** — ✅. FR-1 (`stages`, фильтр терминалов `{done,cancelled}`), FR-2 (`queue`: + counts+`cancelled`, depth, retries, breaker, max_concurrency), FR-3 (`agents`-liveness: + agent/run_id/job_id/pid/runtime_s/model/effort + `cpu_ticks`), FR-4 (`cost`: running+aggregate), + FR-5 (конверт `schema_version`/`generated_at`/`clk_tck`), FR-6 (never-raise по разделам) — + реализованы. AC-1…AC-8 проверены по коду и тестам (TC-01…TC-11), все зелёные. +2. **Соответствие ADR** — ✅. D1–D8 реализованы как описано: D3 фильтр терминалов на потребителе + (helper-инвариант ORCH-053/086 не тронут); D5 dedicated `get_running_agents()` вместо расширения + hot-path `get_running_jobs()` (ORCH-065); D6 `runtime_s` от `jobs.started_at`; D8 kill-switch + `metrics_endpoint_enabled` (дефолт `True`, `200` с минимальным телом при `False`). Глобальный + инвариант терминального множества `{done,cancelled}` (adr-0026) соблюдён. `validation_alias` + `ORCH_METRICS_ENABLED` — обоснованное усиление D8 (документированное имя контракта реально + управляет флагом), покрыто `tests/test_config.py`. Нарушений глобальных ADR нет. +3. **Качество кода** — ✅. Все колонки БД (`agent_runs.{cost_usd,*_tokens}`, `jobs.{pid,run_id, + started_at,repo,attempts,transient_attempts,available_at}`) сверены — существуют. Парсинг + `/proc//stat` устойчив к пробелам/скобкам в `comm` (`rfind(") ")`, индексы 11/12 = поля + 14/15); `_read_cpu_ticks` never-raise per-pid. Docstrings на всех публичных функциях, тесты + содержательные (живой pid → реальный int, мёртвый/`None` → `null`, бросающий источник → дефолт). +4. **Документация** — ✅ (см. секцию ниже). + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. + +### P2 — Should fix +- Нет. + +### P3 — Nice-to-have (не блокирует) +- [ ] `db.get_running_agents()` вызывается дважды на один запрос `/metrics` — в `_build_agents` и в + `_build_cost` (`src/metrics.py:176` и `:206`) — два идентичных SELECT'а. На типовом объёме + (running-jobs ≤ `max_concurrency`) — пренебрежимо, AC-3 не нарушен; при желании можно выбрать строки + один раз и переиспользовать. Косметика, исправление не требуется для приёмки. + +## Документация + +Обновлена в том же PR (правило «доки = golden source», AC-7) — проверено явно: +- **`docs/architecture/README.md`** — новый компонент «Metrics endpoint» в списке, полный раздел- + контракт «Сырьё-эндпоинт `/metrics` для sidecar» (конверт, разделы, политика `schema_version`, + гарантии read-only/never-raise, kill-switch) и строка в таблице API. Соответствует фактическому + ответу эндпоинта. +- **`CHANGELOG.md`** — запись `## [Unreleased]` с пометкой `ORCH-099` (D1–D8 + тесты + откат). +- **`docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md`** — детальное решение (D1–D8). +- **`docs/architecture/adr/adr-0030-metrics-endpoint.md`** — сквозной ADR (новый компонент + наблюдаемости + публичный контракт), зарегистрирован в `docs/architecture/adr/README.md` (индекс + + «текущий максимум — `0030`»). +- **`.env.example`** — задокументирован `ORCH_METRICS_ENABLED=true`. + +`src/` изменён → документация обновлена (golden source соблюдён). Эпик-обзорные доки `README.md` +«Известные ограничения» этой задачей не затрагиваются (новый компонент, не закрытие ограничения). + +## Регресс / проверки +- `pytest tests/ -q` → **1482 passed** (новые `tests/test_metrics.py` TC-01…TC-11 + `test_config.py` + ×2; регресс `/health`//status//queue зелёный, TC-10). +- Дифф `src/stages.py` / `src/qg/` — пуст; machine-verdict ключи и схема БД — байт-в-байт прежние. +- Read-only подтверждён тестом снимка БД до/после (TC-09); never-raise — TC-05/TC-07. diff --git a/docs/work-items/ORCH-099/13-test-report.md b/docs/work-items/ORCH-099/13-test-report.md new file mode 100644 index 0000000..79bcc08 --- /dev/null +++ b/docs/work-items/ORCH-099/13-test-report.md @@ -0,0 +1,87 @@ +--- +result: PASS +work_item: ORCH-099 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-099 +--- + +# Test Report — ORCH-099 — FND/F1a: лёгкий read-only `GET /metrics` (сырьё для sidecar F1b) + +> Машинный вердикт читается ТОЛЬКО из frontmatter. Канонический ключ — `result:` (UPPERCASE). +> Любой негативный токен (`FAIL`/`BLOCKED`) авторитетен. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (pytest-cov 5.0.0, pytest-asyncio 0.23.8) +- Дата: 2026-06-10 +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-099-fnd-f1a-metrics-agent-liveness` + (ветка `feature/ORCH-099-fnd-f1a-metrics-agent-liveness`) +- Review verdict (`12-review.md`): **APPROVED** — гейт пройден до тестирования. + +## Результаты + +### Полный регресс +`cd && pytest tests/ -v --tb=short` → **1482 passed, 1 warning** за 49.98s. +Прод-контейнер (8500) не трогался; прогон — в рабочем дереве ветки задачи. +Единственный warning — известный PydanticDeprecatedSince20 (`src/config.py:8`), не связан с задачей. + +### Профильная сюита +`pytest tests/test_metrics.py -v` → **14 passed** за 0.96s (TC-01…TC-11; часть TC покрыта +несколькими тест-функциями). Новый код присутствует в worktree: `src/metrics.py` (10 538 байт), +`@app.get("/metrics")` в `src/main.py:216` — тонкая обёртка над `metrics.build_metrics()`. + +### Smoke API (read-only, прод 8500) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK. +- `GET /status` → `{"active_tasks":[...]}` — контракт цел. +- `GET /queue` → ключи на месте; блок **`serial_gate` присутствует** (ORCH-088), **`auto_labels` + присутствует** (ORCH-089) — регресса смока нет. +- `GET /metrics` на проде → `404 Not Found` — **ожидаемо**: новый эндпоинт ещё не задеплоен (стадия + testing, до `deploy`); функционал верифицирован тестами в worktree (TC-08). Не является FAIL. + +### Сопоставление с тест-планом (`04-test-plan.yaml`) +| TC ID | Описание | Тест-функция | Результат | +|-------|----------|--------------|-----------| +| TC-01 | Конверт FR-5: dict с schema_version/generated_at/stages/queue/agents/cost | `test_tc01_envelope_has_all_sections` | PASS | +| TC-02 | stages: активные только; work_item/stage/age_in_stage_s(int)/repo; терминалы исключены | `test_tc02_stages_active_only_with_fields` | PASS | +| TC-03 | queue: counts/max_concurrency/retries/breaker-снимок | `test_tc03_queue_section_fields` | PASS | +| TC-04 | agents: agent/run_id/job_id/pid/runtime_s + CPU-liveness сырьё | `test_tc04_agents_liveness_fields` | PASS | +| TC-05 | liveness never-raise: pid=None / нет /proc → cpu_ticks=null, ответ цел | `test_tc05_dead_or_none_pid_cpu_ticks_null`, `test_tc05_read_cpu_ticks_helper_none_paths` | PASS | +| TC-06 | cost.aggregate: суммы cost_usd/токены; пустая таблица → нули | `test_tc06_cost_aggregate_sums_and_empty_zeros` | PASS | +| TC-07 | never-raise по разделу: бросающий источник/breaker → null/дефолт | `test_tc07_section_source_throws_degrades_not_500`, `test_tc07_breaker_unavailable_is_null` | PASS | +| TC-08 | GET /metrics → 200 + валидный JSON со всеми разделами на засеянной БД | `test_tc08_endpoint_returns_full_payload`, `test_tc08_kill_switch_minimal_body` | PASS | +| TC-09 | read-only: снимок БД до/после идентичен; повтор не меняет состояние | `test_tc09_metrics_is_read_only` | PASS | +| TC-10 | аддитивность: /health//status//queue сохраняют контракт | `test_tc10_existing_endpoints_intact` | PASS | +| TC-11 | пустое состояние: stages=[]/agents=[]/cost нули/queue нули → 200 без исключений | `test_tc11_empty_state_valid` | PASS | + +Все 11 TC из тест-плана выполнены и сопоставлены. Расхождений с `expected: PASS` нет. + +### Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) +| AC | Условие | Покрытие | Результат | +|----|---------|----------|-----------| +| AC-1 | 4 раздела + конверт с полями TRZ §3 | TC-01/02/03/04/06 | PASS | +| AC-2 | /health//status//queue не сломаны | TC-10 + smoke | PASS | +| AC-3 | лёгкость: только локальный SQL + in-memory, без сети/тяжёлых процессов | код `src/metrics.py` (нет сетевых вызовов; только read /proc), профильный прогон 0.96s | PASS | +| AC-4 | never-raise: ошибка поля → null, не 500 | TC-05/TC-07/TC-11 | PASS | +| AC-5 | read-only; STAGE_TRANSITIONS/QG_CHECKS/check_*/схема не тронуты | TC-09 + review (дифф `src/stages.py`/`src/qg/` пуст) | PASS | +| AC-6 | agent-liveness: pid/runtime_s + CPU-сырьё для alive-детекта | TC-04/TC-05 | PASS | +| AC-7 | контракт в README + CHANGELOG | подтверждено review (`12-review.md`, §Документация) | PASS | +| AC-8 | pytest зелёный; есть test_metrics.py | 1482 passed; 14 в test_metrics.py | PASS | + +## Вывод pytest +``` +======================= 1482 passed, 1 warning in 49.98s ======================= +``` +``` +tests/test_metrics.py ........... (14 items) +======================== 14 passed, 1 warning in 0.96s ========================= +``` + +## Итог +PASS — полный регресс (1482) и профильная сюита (14) зелёные; smoke read-only OK +(`serial_gate` + `auto_labels` присутствуют в `/queue`); каждый TC тест-плана выполнен и +сопоставлен с критериями приёмки. Задача готова к переходу на `deploy-staging`. diff --git a/docs/work-items/ORCH-099/14-deploy-log.md b/docs/work-items/ORCH-099/14-deploy-log.md new file mode 100644 index 0000000..c0bbdd3 --- /dev/null +++ b/docs/work-items/ORCH-099/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-099 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/src/config.py b/src/config.py index ec222e4..0e93ae2 100644 --- a/src/config.py +++ b/src/config.py @@ -1,7 +1,7 @@ import logging import re -from pydantic import field_validator +from pydantic import Field, field_validator from pydantic_settings import BaseSettings @@ -819,6 +819,17 @@ class Settings(BaseSettings): # 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash). qg0_title_max: int = 200 + # ORCH-099 (D8): operator off-switch for the read-only GET /metrics endpoint. + # The env var is ORCH_METRICS_ENABLED (explicit validation_alias — the documented + # contract name, ADR-001 D8 / README — overriding the default ORCH_ + field-name + # mapping so the documented switch actually controls the flag). Default True -> + # the endpoint is available out of the box (zero regression vs BRD). False -> + # /metrics returns a minimal parsable body {"schema_version": 1, "enabled": false} + # (200, NOT 404) so the F1b sidecar sees the off-switch explicitly. The endpoint + # is inert / read-only anyway; the flag is a cheap self-hosting insurance on the + # shared prod instance. + metrics_endpoint_enabled: bool = Field(True, validation_alias="ORCH_METRICS_ENABLED") + @field_validator("qg0_title_max", mode="before") @classmethod def _qg0_title_max_default(cls, v): diff --git a/src/db.py b/src/db.py index 3c4c56f..1190ea5 100644 --- a/src/db.py +++ b/src/db.py @@ -1133,6 +1133,100 @@ def get_running_jobs() -> list[dict]: return [dict(r) for r in rows] +def get_running_agents() -> list[dict]: + """ORCH-099 (D5): read-only liveness snapshot of every 'running' job for /metrics. + + A dedicated read-only SELECT — deliberately NOT an extension of + ``get_running_jobs()`` (the job-reaper hot path, ORCH-065): widening that + query under observability needs would migrate a foreign component's invariant. + Each row carries the process identity + cost context the F1b sidecar needs: + * ``job_id`` / ``run_id`` / ``pid`` — process identity (pid may be NULL until + the launcher stamps it / after the process exits); + * ``agent`` / ``repo`` — role and project (the sidecar is multi-project); + * ``running_age_s`` — seconds since ``jobs.started_at`` (the same process + anchor the reaper uses for backstop-liveness, D6); + * ``model`` / ``effort`` — cost context (LEFT JOIN ``agent_runs``); + * the token / ``cost_usd`` columns — current per-run accruals, usually NULL + until the launcher parses the CLI result JSON on finish (honest raw, TR-5). + + A LEFT JOIN on ``run_id`` keeps a job with no ``agent_runs`` row. Read-only; + never mutates. + """ + conn = get_db() + try: + rows = conn.execute( + "SELECT j.id AS job_id, j.run_id AS run_id, j.pid AS pid, " + "j.agent AS agent, j.repo AS repo, j.started_at AS started_at, " + "CAST(strftime('%s','now') - strftime('%s', j.started_at) AS INTEGER) " + " AS running_age_s, " + "r.model AS model, r.effort AS effort, r.cost_usd AS cost_usd, " + "r.input_tokens AS input_tokens, r.output_tokens AS output_tokens, " + "r.cache_read_tokens AS cache_read_tokens, " + "r.cache_creation_tokens AS cache_creation_tokens " + "FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id " + "WHERE j.status='running'" + ).fetchall() + finally: + conn.close() + return [dict(r) for r in rows] + + +def agent_cost_totals() -> dict: + """ORCH-099 (D7): read-only aggregate of cost / tokens over all agent_runs. + + Pure ``SELECT COALESCE(SUM(...),0)`` — an empty ``agent_runs`` table yields + zeros, never an error (TC-06 / TC-11). Read-only; never mutates. + """ + conn = get_db() + try: + row = conn.execute( + "SELECT " + "COALESCE(SUM(cost_usd),0) AS cost_usd, " + "COALESCE(SUM(input_tokens),0) AS input_tokens, " + "COALESCE(SUM(output_tokens),0) AS output_tokens, " + "COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens, " + "COALESCE(SUM(cache_creation_tokens),0) AS cache_creation_tokens " + "FROM agent_runs" + ).fetchone() + finally: + conn.close() + return dict(row) if row else { + "cost_usd": 0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + } + + +def queue_retry_stats() -> dict: + """ORCH-099 (D4): read-only retry raw over UNFINISHED jobs for /metrics.queue. + + Aggregates ``attempts`` / ``transient_attempts`` and counts jobs currently in + backoff (``available_at > now``) across non-terminal jobs (status NOT IN + done/failed/cancelled). Read-only; never mutates. + """ + conn = get_db() + try: + row = conn.execute( + "SELECT " + "COALESCE(SUM(attempts),0) AS total_attempts, " + "COALESCE(SUM(transient_attempts),0) AS total_transient_attempts, " + "COALESCE(MAX(attempts),0) AS max_attempts_seen, " + "COALESCE(SUM(CASE WHEN available_at IS NOT NULL " + " AND available_at > datetime('now') THEN 1 ELSE 0 END),0) AS in_backoff " + "FROM jobs WHERE status NOT IN ('done','failed','cancelled')" + ).fetchone() + finally: + conn.close() + return dict(row) if row else { + "total_attempts": 0, + "total_transient_attempts": 0, + "max_attempts_seen": 0, + "in_backoff": 0, + } + + def reap_running_job( job_id: int, status: str, @@ -1185,13 +1279,20 @@ def get_job(job_id: int) -> dict | None: def job_status_counts() -> dict: - """Return counts grouped by status (for /queue observability).""" + """Return counts grouped by status (for /queue and /metrics observability). + + ORCH-099 (D4): the default dict carries the ``cancelled`` terminal key + (ORCH-090, terminal set ``{done, cancelled}``) so the key is always present + with a 0 default instead of materialising only when a cancelled job exists. + Purely additive — the GROUP BY query is unchanged and pre-existing keys keep + their meaning (no /queue contract break). + """ conn = get_db() rows = conn.execute( "SELECT status, COUNT(*) AS n FROM jobs GROUP BY status" ).fetchall() conn.close() - counts = {"queued": 0, "running": 0, "done": 0, "failed": 0} + counts = {"queued": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0} for r in rows: counts[r["status"]] = r["n"] return counts diff --git a/src/main.py b/src/main.py index 38840bc..64a3981 100644 --- a/src/main.py +++ b/src/main.py @@ -213,6 +213,26 @@ async def queue(): } +@app.get("/metrics") +async def metrics(): + """ORCH-099 (FND/F1a): lightweight read-only raw-signal snapshot for the F1b sidecar. + + A versioned JSON envelope (``schema_version`` / ``generated_at`` / ``clk_tck``) + with four raw-signal sections — ``stages`` (active task stages + age), + ``queue`` (counts / retries / breaker / concurrency), ``agents`` (agent-liveness: + pid / runtime / cpu_ticks), ``cost`` (per-run + aggregate tokens/cost). The + orchestrator emits ONLY raw signal it alone knows; the stateful arbiter + (thresholds / deltas / alerts) is the separate sidecar (BRD §1). + + Thin wrapper over ``metrics.build_metrics()`` (in the style of GET /queue): the + collector is already strictly read-only and never-raise, so no extra error + handling is needed here. Same access level as /queue//status. The format is the + documented contract for the sidecar (docs/architecture/README.md). + """ + from . import metrics as metrics_mod + return metrics_mod.build_metrics() + + @app.post("/serial-gate/unfreeze") async def serial_gate_unfreeze(repo: str = ""): """ORCH-088 (FR-5, ADR-001 D4): manually clear a per-repo rollback-freeze. diff --git a/src/metrics.py b/src/metrics.py new file mode 100644 index 0000000..0106e37 --- /dev/null +++ b/src/metrics.py @@ -0,0 +1,276 @@ +"""ORCH-099 (FND/F1a): lightweight read-only ``/metrics`` raw-signal collector. + +A leaf module that builds a versioned JSON snapshot of the orchestrator's own +raw state for the future observability sidecar (F1b, ``watchdog/``): active task +stages, the job queue, agent-liveness, and cost/tokens. The orchestrator emits +ONLY raw signal it alone knows — the sidecar is the stateful arbiter that +computes thresholds / deltas / alerts (BRD §1, observer separated from observed). + +Design (ADR-001, by образцу ``serial_gate.snapshot()`` / ``cancel.snapshot()``): + * pure, never-raise, no side effects — only reads existing tables + (``tasks`` / ``jobs`` / ``agent_runs``) and the in-memory worker snapshot; + * ``build_metrics()`` assembles the envelope section-by-section, each section in + its own ``try/except`` with a safe default (``None`` / ``[]`` / ``{}``) so a + failing source degrades one field, never the whole endpoint (FR-6, NFR-2); + * strictly read-only — no INSERT/UPDATE/DELETE/CREATE/ALTER, no process control, + no network. Self-hosting-safe on the shared prod instance. + +The endpoint ``GET /metrics`` (``src/main.py``) is a thin wrapper that returns +``build_metrics()`` as-is. +""" +from __future__ import annotations + +import logging +import os +from datetime import datetime, timezone + +logger = logging.getLogger("orchestrator.metrics") + +# Contract version for the sidecar (D2). Additive changes (new field/section) do +# NOT bump it — the sidecar MUST ignore unknown keys and tolerate missing +# optional ones. Bumped ONLY on a breaking change (rename/remove/retype an +# existing field). +SCHEMA_VERSION = 1 + + +def _now_iso() -> str: + """UTC ISO-8601 snapshot timestamp (``...Z``), the orchestrator's own clock. + + Same clock domain as the SQLite ``datetime('now')`` timestamps and the CPU + tick reads, so the sidecar's ``(cpu_ticks, generated_at)`` deltas are immune + to orchestrator↔sidecar clock skew (TR-3). Never raises. + """ + try: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("metrics._now_iso error: %s", e) + return "" + + +def _clk_tck() -> int | None: + """Process-global SC_CLK_TCK (ticks/second) — the basis for converting raw CPU + ticks to seconds on the sidecar side. ``None`` on non-Linux / failure. + """ + try: + return int(os.sysconf("SC_CLK_TCK")) + except Exception as e: # noqa: BLE001 - never-raise (non-Linux / unsupported) + logger.warning("metrics._clk_tck error: %s", e) + return None + + +def _read_cpu_ticks(pid: int | None) -> int | None: + """Sum of utime+stime (CPU ticks) from ``/proc//stat`` — raw liveness signal. + + The orchestrator emits raw ticks and does NOT compute the delta — the sidecar + is the stateless arbiter (it divides ``(ticks₂−ticks₁)/clk_tck`` by the + ``generated_at`` delta to get a CPU fraction; a tiny fraction at a growing + ``runtime_s`` ⇒ a "stuck" candidate). Parsing is robust to spaces in ``comm``: + fields are read AFTER the closing ``") "`` of the process name (canonical + proc-stat read). utime = field 14, stime = field 15 → indices 11 and 12 of the + post-``)`` token list (fields 3.. shift by 3). + + never-raise (NFR-2, AC-6): ``pid is None`` / missing ``/proc/`` (process + died or non-Linux) / any parse error → ``None`` (NOT an exception). The caller + keeps every other field and the whole endpoint intact. + """ + if pid is None: + return None + try: + with open(f"/proc/{int(pid)}/stat", "r") as f: + data = f.read() + rparen = data.rfind(") ") + if rparen == -1: + return None + rest = data[rparen + 2:].split() + # rest[0] = state (field 3); utime = field 14 -> rest[11], stime -> rest[12] + return int(rest[11]) + int(rest[12]) + except Exception: # noqa: BLE001 - dead pid / no /proc / non-Linux -> null + return None + + +def _build_stages() -> list: + """Active (non-terminal) task stages (D3, FR-1). + + Source: ``db.get_active_tasks_for_reconcile()`` (``stage != 'done'`` + SQL + ``age_s``), with an extra ``stage NOT IN ('done','cancelled')`` filter on the + metrics side: that helper deliberately still returns ``cancelled`` tasks for + the reconciler's skip-counter (ORCH-086), but terminal tasks are not raw + observability signal (terminal set ``{done, cancelled}``, ORCH-090). The helper + invariant belongs to ORCH-053/086 — we filter at the consumer, not the source. + """ + from . import db + + rows = db.get_active_tasks_for_reconcile() + out = [] + for t in rows: + if t.get("stage") in ("done", "cancelled"): + continue + out.append({ + "work_item": t.get("work_item_id"), + "stage": t.get("stage"), + "age_in_stage_s": t.get("age_s"), + "repo": t.get("repo"), + "task_id": t.get("id"), + }) + return out + + +def _build_queue() -> dict: + """Job-queue raw signal (D4, FR-2): counts, depth, retries, breaker, concurrency. + + Each sub-source is independently guarded: an uninitialised ``worker`` (e.g. in + a test) degrades to ``breaker: null`` / ``max_concurrency: null`` — never a 500 + (NFR-2). + """ + from . import db + + counts = None + try: + counts = db.job_status_counts() + except Exception as e: # noqa: BLE001 + logger.warning("metrics queue counts error: %s", e) + + retries = None + try: + retries = db.queue_retry_stats() + except Exception as e: # noqa: BLE001 + logger.warning("metrics queue retries error: %s", e) + + breaker = None + max_concurrency = None + poll_interval = None + try: + from .queue_worker import worker + try: + breaker = worker.breaker.snapshot() + except Exception as e: # noqa: BLE001 + logger.warning("metrics breaker snapshot error: %s", e) + max_concurrency = getattr(worker, "max_concurrency", None) + poll_interval = getattr(worker, "poll_interval", None) + except Exception as e: # noqa: BLE001 - worker not initialised + logger.warning("metrics worker access error: %s", e) + + depth = counts.get("queued") if isinstance(counts, dict) else None + return { + "counts": counts, + "depth": depth, + "retries": retries, + "breaker": breaker, + "max_concurrency": max_concurrency, + "poll_interval": poll_interval, + } + + +def _build_agents() -> list: + """Agent-liveness raw signal (D5/D6, FR-3). + + One entry per running job from ``db.get_running_agents()`` with process + identity (``agent`` / ``run_id`` / ``job_id`` / ``pid``), ``runtime_s`` + (= ``running_age_s``, anchored on ``jobs.started_at``, D6), ``model`` / + ``effort``, and the raw ``cpu_ticks`` from ``/proc//stat``. ``pid is + None`` / dead process → ``cpu_ticks: null`` for THAT agent; the rest stays + intact (AC-6, TC-05). + """ + from . import db + + rows = db.get_running_agents() + out = [] + for j in rows: + pid = j.get("pid") + out.append({ + "agent": j.get("agent"), + "run_id": j.get("run_id"), + "job_id": j.get("job_id"), + "repo": j.get("repo"), + "pid": pid, + "runtime_s": j.get("running_age_s"), + "model": j.get("model"), + "effort": j.get("effort"), + "cpu_ticks": _read_cpu_ticks(pid), + }) + return out + + +def _build_cost() -> dict: + """Cost / token raw signal (D7, FR-4). + + ``running`` — current per-running-job accruals from ``agent_runs`` (often + ``null`` until the job finishes and the launcher parses the CLI JSON — ``null`` + is honest raw, NOT zero, TR-5). ``aggregate`` — summed totals over all + ``agent_runs`` (empty table → zeros, TC-06/TC-11). + """ + from . import db + + running = [] + try: + for j in db.get_running_agents(): + running.append({ + "run_id": j.get("run_id"), + "job_id": j.get("job_id"), + "agent": j.get("agent"), + "cost_usd": j.get("cost_usd"), + "input_tokens": j.get("input_tokens"), + "output_tokens": j.get("output_tokens"), + "cache_read_tokens": j.get("cache_read_tokens"), + "cache_creation_tokens": j.get("cache_creation_tokens"), + }) + except Exception as e: # noqa: BLE001 + logger.warning("metrics cost.running error: %s", e) + running = [] + + aggregate = None + try: + aggregate = db.agent_cost_totals() + except Exception as e: # noqa: BLE001 + logger.warning("metrics cost.aggregate error: %s", e) + + return {"running": running, "aggregate": aggregate} + + +def build_metrics() -> dict: + """Assemble the ``/metrics`` envelope (FR-5). never-raise (FR-6, NFR-2, AC-4). + + Each section is collected in its own ``try/except`` with a safe default so a + failing source degrades one section, not the whole response. Honours the + ``metrics_endpoint_enabled`` kill-switch (D8): when off, returns a minimal + parsable body ``{"schema_version", "enabled": false}`` (200, NOT 404) so the + sidecar sees the off-switch explicitly. + """ + try: + from .config import settings + if not bool(getattr(settings, "metrics_endpoint_enabled", True)): + return {"schema_version": SCHEMA_VERSION, "enabled": False} + except Exception as e: # noqa: BLE001 - kill-switch read must never break /metrics + logger.warning("metrics kill-switch read error: %s", e) + + out: dict = { + "schema_version": SCHEMA_VERSION, + "generated_at": _now_iso(), + "clk_tck": _clk_tck(), + } + + try: + out["stages"] = _build_stages() + except Exception as e: # noqa: BLE001 + logger.warning("metrics stages section error: %s", e) + out["stages"] = [] + + try: + out["queue"] = _build_queue() + except Exception as e: # noqa: BLE001 + logger.warning("metrics queue section error: %s", e) + out["queue"] = None + + try: + out["agents"] = _build_agents() + except Exception as e: # noqa: BLE001 + logger.warning("metrics agents section error: %s", e) + out["agents"] = [] + + try: + out["cost"] = _build_cost() + except Exception as e: # noqa: BLE001 + logger.warning("metrics cost section error: %s", e) + out["cost"] = None + + return out diff --git a/tests/test_config.py b/tests/test_config.py index b43a179..899259b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -320,3 +320,20 @@ def test_deploy_status_guard_settings_env_override(monkeypatch): s = Settings() assert s.deploy_status_guard_enabled is False assert s.deploy_status_guard_repos == "orchestrator,enduro-trails" + + +# --------------------------------------------------------------------------- +# ORCH-099 (D8): metrics_endpoint_enabled default + env alias ORCH_METRICS_ENABLED. +# The field carries an explicit validation_alias so the DOCUMENTED env var +# (README / ADR-001 D8) actually controls the flag, overriding the default +# ORCH_ + field-name mapping (which would otherwise be ORCH_METRICS_ENDPOINT_*). +# --------------------------------------------------------------------------- +def test_metrics_endpoint_enabled_default_true(monkeypatch): + monkeypatch.delenv("ORCH_METRICS_ENABLED", raising=False) + monkeypatch.delenv("ORCH_METRICS_ENDPOINT_ENABLED", raising=False) + assert Settings().metrics_endpoint_enabled is True + + +def test_metrics_endpoint_enabled_reads_documented_env_alias(monkeypatch): + monkeypatch.setenv("ORCH_METRICS_ENABLED", "false") + assert Settings().metrics_endpoint_enabled is False diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..0347193 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,295 @@ +"""ORCH-099 (FND/F1a) — read-only GET /metrics raw-signal endpoint. + +Covers the four-section envelope (TC-01..TC-04/TC-08/TC-11), never-raise by +section/field (TC-05/TC-07), the cost aggregate (TC-06), read-only invariant +(TC-09), and additivity vs /health//status//queue (TC-10). + +Pattern mirrors tests/test_queue_endpoint.py: the async handler is driven via +asyncio.run(main.metrics()); the autouse conftest mutes Telegram; a per-test +fresh_db points settings.db_path at a tmp file + init_db. +""" +import asyncio +import os + +import pytest + +import src.db as db # noqa: E402 +from src.db import get_db, init_db # noqa: E402 +from src import config as cfg # noqa: E402 +from src import metrics as metrics_mod # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + dbfile = tmp_path / "metrics.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "metrics_endpoint_enabled", True, raising=False) + init_db() + yield + + +# --- helpers --------------------------------------------------------------- +def _make_task(work_item_id="ORCH-1", repo="orchestrator", + branch="feature/x", stage="development"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (work_item_id, work_item_id, repo, branch, stage), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _make_agent_run(agent="developer", task_id=None, model="claude-opus-4-8", + effort="xhigh", cost_usd=None, input_tokens=None, + output_tokens=None, cache_read_tokens=None, + cache_creation_tokens=None, finished=False): + conn = get_db() + cur = conn.execute( + "INSERT INTO agent_runs (task_id, agent, model, effort, cost_usd, " + "input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, " + "finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, " + + ("datetime('now')" if finished else "NULL") + ")", + (task_id, agent, model, effort, cost_usd, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens), + ) + rid = cur.lastrowid + conn.commit() + conn.close() + return rid + + +def _make_running_job(agent="developer", repo="orchestrator", task_id=None, + pid=None, run_id=None, age_s=0, attempts=0, max_attempts=2): + conn = get_db() + cur = conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, " + "run_id, pid, started_at) " + "VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))", + (agent, repo, task_id, attempts, max_attempts, run_id, pid, + f"-{int(age_s)} seconds"), + ) + job_id = cur.lastrowid + conn.commit() + conn.close() + return job_id + + +def _db_snapshot(): + """Full row snapshot of the mutable tables for the read-only assertion.""" + conn = get_db() + snap = {} + for table in ("tasks", "jobs", "agent_runs"): + rows = conn.execute(f"SELECT * FROM {table} ORDER BY id").fetchall() + snap[table] = [dict(r) for r in rows] + conn.close() + return snap + + +# --- TC-01: envelope keys -------------------------------------------------- +def test_tc01_envelope_has_all_sections(): + m = metrics_mod.build_metrics() + assert isinstance(m, dict) + for key in ("schema_version", "generated_at", "stages", "queue", "agents", "cost"): + assert key in m, f"missing envelope key {key!r}" + assert m["schema_version"] == 1 + assert isinstance(m["stages"], list) + assert isinstance(m["agents"], list) + assert isinstance(m["queue"], dict) + assert isinstance(m["cost"], dict) + + +# --- TC-02: stages section + terminal exclusion ---------------------------- +def test_tc02_stages_active_only_with_fields(): + _make_task(work_item_id="ORCH-10", stage="development", repo="orchestrator") + _make_task(work_item_id="ORCH-11", stage="done") # terminal -> excluded + _make_task(work_item_id="ORCH-12", stage="cancelled") # terminal -> excluded + + stages = metrics_mod.build_metrics()["stages"] + wis = {s["work_item"] for s in stages} + assert "ORCH-10" in wis + assert "ORCH-11" not in wis + assert "ORCH-12" not in wis + + item = next(s for s in stages if s["work_item"] == "ORCH-10") + assert item["stage"] == "development" + assert item["repo"] == "orchestrator" + assert isinstance(item["age_in_stage_s"], int) + + +# --- TC-03: queue section -------------------------------------------------- +def test_tc03_queue_section_fields(): + q = metrics_mod.build_metrics()["queue"] + assert "counts" in q + counts = q["counts"] + for k in ("queued", "running", "failed", "cancelled"): + assert k in counts + assert q["max_concurrency"] is not None + assert "retries" in q and isinstance(q["retries"], dict) + assert "in_backoff" in q["retries"] + # breaker snapshot present (worker is the module singleton, initialised) + assert q["breaker"] is not None + for k in ("state", "consecutive_transient", "pause_remaining_s"): + assert k in q["breaker"] + + +# --- TC-04: agents liveness section ---------------------------------------- +def test_tc04_agents_liveness_fields(): + tid = _make_task(work_item_id="ORCH-20") + rid = _make_agent_run(task_id=tid, model="claude-opus-4-8", effort="xhigh") + # use our own (alive) pid so cpu_ticks is a real integer + _make_running_job(task_id=tid, pid=os.getpid(), run_id=rid, age_s=5) + + agents = metrics_mod.build_metrics()["agents"] + assert len(agents) == 1 + a = agents[0] + for k in ("agent", "run_id", "job_id", "pid", "runtime_s", "model", "effort", "cpu_ticks"): + assert k in a, f"agent entry missing {k!r}" + assert a["agent"] == "developer" + assert a["run_id"] == rid + assert a["pid"] == os.getpid() + assert isinstance(a["runtime_s"], int) + # alive pid -> real cpu ticks (int), basis present at envelope level + assert isinstance(a["cpu_ticks"], int) + assert metrics_mod.build_metrics()["clk_tck"] is not None + + +# --- TC-05: agent-liveness never-raise on dead/None pid -------------------- +def test_tc05_dead_or_none_pid_cpu_ticks_null(): + tid = _make_task(work_item_id="ORCH-21") + rid = _make_agent_run(task_id=tid) + # pid=None -> cpu_ticks null; a very-unlikely-live pid -> /proc absent -> null + _make_running_job(task_id=tid, pid=None, run_id=rid) + _make_running_job(task_id=tid, pid=999999, run_id=rid) + + m = metrics_mod.build_metrics() + agents = m["agents"] + assert len(agents) == 2 + for a in agents: + assert a["cpu_ticks"] is None # field degraded, not an exception + assert a["agent"] == "developer" # other fields intact + # whole envelope still valid + assert m["schema_version"] == 1 + + +def test_tc05_read_cpu_ticks_helper_none_paths(): + assert metrics_mod._read_cpu_ticks(None) is None + assert metrics_mod._read_cpu_ticks(999999) is None + # alive pid (this process) -> int + assert isinstance(metrics_mod._read_cpu_ticks(os.getpid()), int) + + +# --- TC-06: cost aggregate ------------------------------------------------- +def test_tc06_cost_aggregate_sums_and_empty_zeros(): + # empty agent_runs -> zeros, not error + agg0 = metrics_mod.build_metrics()["cost"]["aggregate"] + for k in ("cost_usd", "input_tokens", "output_tokens", + "cache_read_tokens", "cache_creation_tokens"): + assert agg0[k] == 0 + + tid = _make_task(work_item_id="ORCH-30") + _make_agent_run(task_id=tid, cost_usd=1.5, input_tokens=100, output_tokens=20, + cache_read_tokens=5, cache_creation_tokens=7, finished=True) + _make_agent_run(task_id=tid, cost_usd=2.5, input_tokens=200, output_tokens=30, + cache_read_tokens=10, cache_creation_tokens=3, finished=True) + + agg = metrics_mod.build_metrics()["cost"]["aggregate"] + assert agg["cost_usd"] == 4.0 + assert agg["input_tokens"] == 300 + assert agg["output_tokens"] == 50 + assert agg["cache_read_tokens"] == 15 + assert agg["cache_creation_tokens"] == 10 + + +# --- TC-07: never-raise when a section source throws ----------------------- +def test_tc07_section_source_throws_degrades_not_500(monkeypatch): + def _boom(*a, **k): + raise RuntimeError("simulated source failure") + + # queue counts source throws -> queue.counts null, build_metrics still returns + monkeypatch.setattr(db, "job_status_counts", _boom) + # cost aggregate source throws -> cost.aggregate null + monkeypatch.setattr(db, "agent_cost_totals", _boom) + # stages source throws -> stages [] + monkeypatch.setattr(db, "get_active_tasks_for_reconcile", _boom) + + m = metrics_mod.build_metrics() + assert m["schema_version"] == 1 # never raised + assert m["stages"] == [] + assert m["queue"]["counts"] is None + assert m["cost"]["aggregate"] is None + + +def test_tc07_breaker_unavailable_is_null(monkeypatch): + from src import queue_worker + # simulate an uninitialised / broken worker breaker + monkeypatch.setattr(queue_worker.worker.breaker, "snapshot", + lambda: (_ for _ in ()).throw(RuntimeError("no breaker"))) + q = metrics_mod.build_metrics()["queue"] + assert q["breaker"] is None # null, not 500 + + +# --- TC-08: GET /metrics via handler returns valid JSON -------------------- +def test_tc08_endpoint_returns_full_payload(): + tid = _make_task(work_item_id="ORCH-40") + rid = _make_agent_run(task_id=tid) + _make_running_job(task_id=tid, pid=os.getpid(), run_id=rid) + + from src import main + payload = asyncio.run(main.metrics()) + assert payload["schema_version"] == 1 + assert isinstance(payload["stages"], list) and len(payload["stages"]) == 1 + assert isinstance(payload["agents"], list) and len(payload["agents"]) == 1 + assert "aggregate" in payload["cost"] + assert "counts" in payload["queue"] + + +def test_tc08_kill_switch_minimal_body(monkeypatch): + monkeypatch.setattr(cfg.settings, "metrics_endpoint_enabled", False, raising=False) + from src import main + payload = asyncio.run(main.metrics()) + assert payload == {"schema_version": 1, "enabled": False} + + +# --- TC-09: read-only invariant -------------------------------------------- +def test_tc09_metrics_is_read_only(): + tid = _make_task(work_item_id="ORCH-50") + rid = _make_agent_run(task_id=tid, cost_usd=1.0, input_tokens=10) + _make_running_job(task_id=tid, pid=os.getpid(), run_id=rid) + + from src import main + before = _db_snapshot() + asyncio.run(main.metrics()) + asyncio.run(main.metrics()) # repeat: state must not change + after = _db_snapshot() + assert before == after, "/metrics must not mutate any DB state" + + +# --- TC-10: additivity vs existing endpoints ------------------------------- +def test_tc10_existing_endpoints_intact(): + from src import main + health = asyncio.run(main.health()) + assert health["status"] == "ok" + + status = asyncio.run(main.status()) + assert "active_tasks" in status + + queue = asyncio.run(main.queue()) + for key in ("counts", "max_concurrency", "poll_interval", "resilience", + "reconcile", "reaper", "serial_gate", "recent"): + assert key in queue, f"/queue lost existing key {key!r}" + + +# --- TC-11: empty state is valid ------------------------------------------- +def test_tc11_empty_state_valid(): + m = metrics_mod.build_metrics() + assert m["stages"] == [] + assert m["agents"] == [] + assert m["cost"]["running"] == [] + agg = m["cost"]["aggregate"] + assert all(agg[k] == 0 for k in agg) + counts = m["queue"]["counts"] + assert counts["queued"] == 0 and counts["running"] == 0