architect(ET): auto-commit from architect run_id=542
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -31,11 +31,16 @@ Per-work-item решения живут в `docs/work-items/<id>/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).
|
||||
|
||||
88
docs/architecture/adr/adr-0030-metrics-endpoint.md
Normal file
88
docs/architecture/adr/adr-0030-metrics-endpoint.md
Normal file
@@ -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/<pid>/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/`, отдельная задача).
|
||||
249
docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md
Normal file
249
docs/work-items/ORCH-099/06-adr/ADR-001-metrics-endpoint.md
Normal file
@@ -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/<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-контракт —
|
||||
стиль версионируемого контракта).
|
||||
43
docs/work-items/ORCH-099/10-tech-risks.md
Normal file
43
docs/work-items/ORCH-099/10-tech-risks.md
Normal file
@@ -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/<pid>/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/<pid>` валиден в том же 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), без рестарта прод-контейнера.
|
||||
Reference in New Issue
Block a user