From 8988dca14d38030e21a113b491d91ca2bd2cbc79 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 01:45:31 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=542 --- docs/architecture/README.md | 53 ++++ docs/architecture/adr/README.md | 7 +- .../adr/adr-0030-metrics-endpoint.md | 88 +++++++ .../06-adr/ADR-001-metrics-endpoint.md | 249 ++++++++++++++++++ docs/work-items/ORCH-099/10-tech-risks.md | 43 +++ 5 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr/adr-0030-metrics-endpoint.md create mode 100644 docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md create mode 100644 docs/work-items/ORCH-099/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ae4a622..cfd222f 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 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/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), без рестарта прод-контейнера.