---
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/<pid>/stat, поля 14 (utime) + 15 (stime), возвращает их сумму (в тиках);
    # pid is None / нет /proc/<pid> / гонка (процесс умер) / не-Linux -> None (НЕ raise)
```

`clk_tck` (D2) — на уровне конверта. sidecar между двумя опросами считает
`cpu_busy = (ticks₂ − ticks₁) / clk_tck`, делит на `(generated_at₂ − generated_at₁)` → доля CPU;
малая доля при растущем `runtime_s` ⇒ кандидат на «завис». Парсинг `/proc/<pid>/stat` устойчив к
пробелам в `comm`: брать поля **после** `') '` (закрывающая скобка имени) — канон чтения proc-stat.

Инвариант (NFR-2, AC-6, TC-05): `pid is None` ИЛИ мёртвый/отсутствующий `/proc/<pid>` → `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-контракт —
  стиль версионируемого контракта).
