architect(ET): auto-commit from architect run_id=574
This commit is contained in:
244
docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md
Normal file
244
docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
work_item: ORCH-098
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-10
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# ADR-001: Машинный журнал уроков `lessons` — аддитивная таблица + observer-leaf
|
||||
|
||||
Work Item: **ORCH-098** — FND: машинный журнал уроков (структурированная база отклонений конвейера)
|
||||
Стадия: **architecture**
|
||||
Сквозная регистрация: **`docs/architecture/adr/adr-0033-lessons-journal.md`** (решение
|
||||
кросс-каттинговое: новый компонент + новая таблица на общей прод-БД + фундамент эпика
|
||||
саморазвития).
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
## Контекст
|
||||
|
||||
ORCH-098 — шаг 1 («Фундамент», F2) эпика саморазвития: формализовать свободнотекстовые «уроки»
|
||||
из `memory/` в **машинную структурированную таблицу отклонений конвейера**, на которой позже
|
||||
встанут ретроспективщик (E2), приоритизатор RICE (E3) и Стрим. BRD/TRZ уже зафиксировали состав
|
||||
полей, набор эндпоинтов и структуру leaf-модуля; нормативное требование Славы 10.06 — колонки
|
||||
**атрибуции** в схеме **с самого начала** (нуллабельные), чтобы не переделывать схему на живой
|
||||
общей прод-БД.
|
||||
|
||||
Сверено по коду (recon):
|
||||
- **Образец observer-leaf**: `src/serial_gate.py`, `src/coverage_gate.py`, `src/metrics.py` —
|
||||
чистые leaf'ы, импортируют только `config`+`db`, `applies(repo)`-first, never-raise, `snapshot()`
|
||||
для `GET /queue`.
|
||||
- **БД-паттерн**: `db.get_db() -> sqlite3.Connection` (`row_factory=sqlite3.Row`, `.close()` в
|
||||
`finally`); `db.init_db()` — `executescript` с `CREATE TABLE IF NOT EXISTS …`; идемпотентные
|
||||
миграции `_ensure_column(conn, table, column, decl)` (`src/db.py:341`). Эталон аддитивной таблицы
|
||||
— `repo_freeze`, `coverage_baseline`; атомарный helper — `ratchet_coverage_baseline` (`db.py:251`).
|
||||
- **Choke-point'ы автозаписи** (точные сигнатуры):
|
||||
- `stage_engine._handle_qg_failure_rollbacks(task_id, current_stage, repo, work_item_id, branch,
|
||||
agent, qg_name, reason, result)` (`src/stage_engine.py:728`) — все нужные поля контекста
|
||||
локально доступны.
|
||||
- post-deploy `DEGRADED → set_repo_freeze` (`src/stage_engine.py:~1993`) — доступны `repo`,
|
||||
`work_item_id`, `branch`, локально собранный `reason`.
|
||||
- `merge_gate._handle_merge_verify(task_id, repo, work_item_id, branch, result)`
|
||||
(`src/merge_gate.py:1588`); ветка HOLD ставит `result.note="merge-not-verified-hold"` (~`:1695`).
|
||||
- `merge_gate._classify_merge_response(repo, branch, index, status_code) -> "transient"|"terminal"`
|
||||
(`src/merge_gate.py:811`).
|
||||
- `launcher._watchdog`/`stop_process` (timeout-kill) и `launcher._finalize_transient(job_id, agent,
|
||||
run_id, exit_code, job, retry_after)` (`src/agents/launcher.py:997`) — транзиент-requeue с
|
||||
бюджетом `transient_attempts`.
|
||||
- **Конфиг-паттерн**: pydantic `BaseSettings` с авто-биндингом `ORCH_*`; пары `*_enabled` (bool) +
|
||||
`*_repos` (CSV); `is_self_hosting_repo(repo)` (`src/qg/checks.py:520`).
|
||||
|
||||
«Как есть» не годится: уроки в `memory/` не машиночитаемы — нельзя считать паттерны, нельзя
|
||||
приоритизировать. Нужна структурированная таблица, но врезанная в **горячий путь** конвейера, что
|
||||
на self-hosting прод-инстансе с общей БД (enduro-trails) требует жёсткой изоляции.
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
|
||||
Ввести **аддитивную идемпотентную таблицу `lessons`** + **чистый observer-leaf `src/lessons.py`**
|
||||
(never-raise, kill-switch) по образцу `serial_gate`/`coverage_gate`/`metrics`. Leaf несёт
|
||||
`record()` / `get()` / `update()` / `snapshot()`. Автозапись 4 типов отклонений — тонкими
|
||||
best-effort врезками в существующие choke-point. Два-три HTTP-эндпоинта в `main.py`. Колонки
|
||||
атрибуции — в схеме сразу, нуллабельные. **Конвейер (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
|
||||
machine-verdict) и схемы существующих таблиц — байт-в-байт не тронуты; enduro не затронут.**
|
||||
|
||||
### D1 — Таблица `lessons`: аддитивная, идемпотентная, forward-proof (BR-1, BR-2; AC-1, AC-2)
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS lessons (…)` в `db.init_db()` (паттерн `repo_freeze`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS lessons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT,
|
||||
lesson_type TEXT NOT NULL, -- slug-конвенция, НЕ enum-констрейнт
|
||||
work_item_id TEXT,
|
||||
task_id INTEGER,
|
||||
stage TEXT,
|
||||
agent TEXT,
|
||||
repo TEXT,
|
||||
root_cause TEXT,
|
||||
suggestion TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'new', -- new|in_progress|closed|linked
|
||||
related_task TEXT,
|
||||
attribution TEXT, -- platform|project|both|unknown (NULLABLE)
|
||||
target_repo TEXT, -- orchestrator|enduro-trails|… (NULLABLE)
|
||||
target_domain TEXT, -- reliability|quality|economy|features|scale (NULLABLE)
|
||||
source TEXT, -- auto|manual
|
||||
detail TEXT -- свободный JSON/текст (payload детектора)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lessons_type_status ON lessons (lesson_type, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_lessons_repo ON lessons (repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_lessons_wi_type ON lessons (work_item_id, lesson_type);
|
||||
```
|
||||
|
||||
**Инварианты:**
|
||||
- Все три колонки **атрибуции создаются сразу и нуллабельны** (NFR-6, требование Славы 10.06): на
|
||||
живой уже-существующей таблице добавляются через `_ensure_column(conn, "lessons", "<col>",
|
||||
"TEXT")` — forward-safe, restart-safe, без миграции данных.
|
||||
- **Нет `enum`/`CHECK`-констрейнта** на `lesson_type`/`attribution`/`target_domain` — значения суть
|
||||
конвенция строковых слагов (новый тип урока не требует миграции схемы; §6 допущений BRD).
|
||||
- **Третий индекс `idx_lessons_wi_type`** добавлен сверх двух из TRZ — обслуживает дедуп-запрос
|
||||
автозаписи (D4) одним indexed-lookup'ом (NFR-5).
|
||||
|
||||
DDL-хелперы в `db.py` (стиль `coverage_baseline`): `record_lesson(...) -> int|None`,
|
||||
`get_lessons(...) -> list[dict]`, `update_lesson(id, **fields) -> bool`, `lessons_snapshot() -> dict`.
|
||||
Каждый открывает `get_db()` и закрывает в `finally`.
|
||||
|
||||
### D2 — Observer-leaf `src/lessons.py`: scope **kill-switch only**, НЕ repo-gated (BR-3/4/5/6; NFR-1/2/7)
|
||||
|
||||
Чистый leaf, импортирует только `config`+`db` (lazy `notifications` при необходимости); **никогда
|
||||
не импортирует `stage_engine`/`merge_gate`/`launcher`** (анти-цикл). Публичный контракт:
|
||||
|
||||
```python
|
||||
def record(lesson_type, *, work_item_id=None, task_id=None, stage=None, agent=None, repo=None,
|
||||
root_cause=None, suggestion=None, status="new", related_task=None, attribution=None,
|
||||
target_repo=None, target_domain=None, source="auto", detail=None) -> int | None
|
||||
def get(*, lesson_type=None, status=None, repo=None, work_item_id=None, limit=None) -> list[dict]
|
||||
def update(lesson_id, **fields) -> bool
|
||||
def snapshot() -> dict
|
||||
```
|
||||
|
||||
**Ключевое решение D2 — расхождение с шаблоном гейт-leaf'ов: журнал НЕ скоупится по repo.**
|
||||
В отличие от `serial_gate`/`coverage_gate`/`bug_fast_track` (которые *действуют* на конкретный репо
|
||||
и потому имеют пару `*_repos`), журнал — **observer-only**: запись строки никогда не влияет на
|
||||
пайплайн ни одного репо. Поэтому:
|
||||
- единственный регулятор — глобальный kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`,
|
||||
дефолт `True`); **`lessons_repos` НЕ вводится**;
|
||||
- recorder пишет уроки про **любой** репо (включая enduro-trails) — урок про деградацию деплоя
|
||||
enduro ценен для петли самообучения; репо-скоуп терял бы этот сигнал;
|
||||
- `repo`-разрез — на уровне **выборки** (`get(repo=…)`, фильтр `snapshot()`), как зафиксировано в
|
||||
§6 BRD «репо-скоуп через поле/фильтр выборки».
|
||||
- **enduro не затронут (NFR-3):** запись observer-строки про enduro не меняет ни одной стадии/гейта
|
||||
enduro — это чистая память орка.
|
||||
|
||||
**never-raise (NFR-1, AC-6):** при `lessons_enabled is False` каждая функция — немедленный no-op
|
||||
(`record→None`, `get→[]`, `update→False`, `snapshot→{}`) **без обращения к БД**. При `True` — тело в
|
||||
`try/except Exception → logger.warning(...) + безопасный дефолт`. Журнал **не** деплоит, **не**
|
||||
рестартит прод, **не** трогает `main`, **не** порождает процессов/сети (NFR-7).
|
||||
|
||||
### D3 — Точки автозаписи: 4 детектора, тонкая врезка одним вызовом (BR-3; FR-3; AC-3)
|
||||
|
||||
Каждая врезка = локальный импорт + один вызов `lessons.record(...)`, обёрнутый в защитный
|
||||
`try/except` (паттерн post-deploy-freeze-врезки `stage_engine.py:~1993`), чтобы даже ошибка импорта
|
||||
не пробилась в горячий путь:
|
||||
|
||||
| Тип (`lesson_type`) | Choke-point | Контекст врезки |
|
||||
|---|---|---|
|
||||
| `gate_failure` | `stage_engine._handle_qg_failure_rollbacks` (после решения об откате на `development`) | `work_item_id, task_id, stage=current_stage, agent, repo, root_cause=reason, detail=qg_name` |
|
||||
| `merge_hold` | `merge_gate._handle_merge_verify` (ветка HOLD, `result.note="merge-not-verified-hold"`) | `work_item_id, task_id, repo, stage="deploy", root_cause="merge-not-verified-hold"` |
|
||||
| `transient_retry` | **budget-exhaustion**: `merge_gate` (merge-retry исчерпан) и/или `launcher._finalize_transient` (исчерпан `transient_attempts`) | `work_item_id?, repo, agent?, stage?, detail=<код/причина>` |
|
||||
| `deploy_degraded` | `stage_engine` post-deploy `DEGRADED → set_repo_freeze` | `work_item_id, repo, stage="deploy", root_cause=reason, attribution="unknown", target_repo=repo, target_domain="reliability"` |
|
||||
|
||||
Все врезки — `source="auto"`. Это **4 типа > минимума 2–3** (BR-3). `(г) deploy_degraded` (желаемый
|
||||
по TRZ) включён как полноценный детектор: это урок слоя-3 «деплой OK / прод сломан» (ET-8),
|
||||
ради которого Слава и потребовал атрибуцию.
|
||||
|
||||
### D4 — Дедуп автозаписи: один indexed-SELECT в окне (BR-3; FR-3 «решение архитектора»; NFR-5)
|
||||
|
||||
Риск: транзиент-ретраи/повторные откаты плодят дубли. Решение — **дешёвый дедуп только для
|
||||
`source="auto"`** внутри `record()`: перед `INSERT` — один indexed-lookup
|
||||
```sql
|
||||
SELECT 1 FROM lessons
|
||||
WHERE work_item_id = ? AND lesson_type = ? AND (stage IS ? OR ?) -- stage-match
|
||||
AND created_at > datetime('now', ?) -- '-<window> seconds'
|
||||
LIMIT 1;
|
||||
```
|
||||
по индексу `idx_lessons_wi_type` (D1). Найдено → no-op (`return None`, лог DEBUG). Окно —
|
||||
`lessons_dedup_window_s` (env `ORCH_LESSONS_DEDUP_WINDOW_S`, дефолт `3600`). **`source="manual"`
|
||||
дедуп НЕ проходит** (оператор/Стрим всегда может записать). Это один лёгкий `SELECT` (NFR-5), без
|
||||
фоновых сканов.
|
||||
|
||||
**Доп. контроль флуда на самом шумном детекторе:** `transient_retry` пишется **только на исчерпании
|
||||
бюджета ретраев** (а не на каждом backoff) — это и есть ценный сигнал «транзиенты исчерпаны», а не
|
||||
шум каждой попытки. Так флуд гасится в источнике до дедупа.
|
||||
|
||||
### D5 — Эндпоинты `main.py`: read-only выборка + ручная запись/обновление (BR-4/5/6; FR-4/5; AC-4/5)
|
||||
|
||||
Стиль `GET /queue` / `POST /coverage/baseline`, все never-raise, при выключенном флаге →
|
||||
`{"enabled": false}`:
|
||||
- **`GET /lessons`** — query `type/status/repo/work_item/limit` (дефолт `lessons_query_limit_default`,
|
||||
напр. 100) → `{"enabled": bool, "lessons": [...]}`, всегда `200`, только чтение.
|
||||
- **`POST /lessons`** — тело JSON, `lesson_type` обязателен → `lessons.record(..., source="manual")`
|
||||
→ `{"id": <int>}`.
|
||||
- **`POST /lessons/{id}`** — `lessons.update(id, status=…, attribution=…, target_repo=…,
|
||||
target_domain=…, related_task=…, root_cause=…, suggestion=…)` → `{"ok": bool}`; стампит
|
||||
`updated_at=datetime('now')`. Позволяет ретроспективщику/человеку доклассифицировать
|
||||
автозаписанный `unknown`.
|
||||
- **`GET /queue`** — добавить read-only ключ `"lessons": lessons.snapshot()` рядом с
|
||||
`serial_gate`/`coverage`. `snapshot()` — лёгкие `GROUP BY`-счётчики (по типу/статусу) + последние
|
||||
N. Существующие ключи `/queue` и эндпоинты `/health|/status|/metrics` — **байт-в-байт прежние**.
|
||||
|
||||
### D6 — Изоляция от конвейера и гейтов (NFR-3; AC-8)
|
||||
|
||||
`STAGE_TRANSITIONS`, реестр `QG_CHECKS`, функции `check_*`, machine-verdict-ключи
|
||||
(`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`) и
|
||||
схемы существующих таблиц — **диффом не затрагиваются**. Журнал — наблюдатель, **не** Quality Gate;
|
||||
он не участвует в решении о продвижении по стадиям. Никаких новых/изменённых QG-checks (FR-6).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Repo-скоуп `lessons_repos` (как у гейтов)** — отвергнуто: журнал observer-only, не действует на
|
||||
репо; скоуп терял бы ценные enduro-уроки. Скоуп — на выборке (D2).
|
||||
- **Без дедупа в v1 (TRZ это допускает)** — отвергнуто как дефолт: транзиент-ретраи реально
|
||||
флудят таблицу; дешёвый indexed-дедуп (D4) дешевле, чем последующая чистка. Бюджет-exhaustion +
|
||||
окно дают двойную защиту при одном `SELECT`.
|
||||
- **Запись `transient_retry` на каждом backoff** — отвергнуто: шум; ценен факт исчерпания бюджета.
|
||||
- **Отдельная БД/файл для журнала** — отвергнуто: лишняя зависимость; общая SQLite-БД с аддитивной
|
||||
таблицей соответствует принципу «минимум зависимостей» и паттерну `repo_freeze`/`coverage_baseline`.
|
||||
- **Фоновый агрегатор/ретенция-крон в v1** — отвергнуто: NFR-5 (без фоновых потоков/сканов);
|
||||
ретенция — будущая задача (см. `10-tech-risks.md` TR-2).
|
||||
- **ORM** — отвергнуто: raw SQL достаточно (принцип «без ORM, если хватает raw SQL»).
|
||||
|
||||
## Последствия
|
||||
|
||||
- **+** Уроки становятся машиночитаемыми — фундамент для E2/E3/Стрим; атрибуция forward-proof
|
||||
(колонки сразу, переделки живой БД не будет).
|
||||
- **+** Нулевая регрессия: kill-switch + never-raise + чистая аддитивность; enduro не затронут;
|
||||
конвейер байт-в-байт прежний.
|
||||
- **+** Следует проверенному additive-observer-leaf шаблону (`serial_gate`/`coverage_gate`/`metrics`/
|
||||
`cancel`/`bug_fast_track`) — низкий архитектурный риск, не требует `arch:major-change` (см.
|
||||
`10-tech-risks.md` сводный вывод).
|
||||
- **−** Рост таблицы со временем (автозапись на отклонениях). Митигейшн: лёгкие строки + дедуп (D4) +
|
||||
budget-exhaustion-only для транзиентов; ретенция — TR-2 (будущее).
|
||||
- **−** Лёгкое усложнение `record()` дедуп-запросом. Митигейшн: один indexed-SELECT, только для
|
||||
`auto`, под окном; для `manual` пропускается.
|
||||
- **Откат:** `ORCH_LESSONS_ENABLED=false` → весь функционал инертен мгновенно (no-op, нулевая
|
||||
регрессия). Полный откат — revert диффа; таблица `lessons` остаётся пустой/неиспользуемой,
|
||||
существующих таблиц не касается.
|
||||
|
||||
## Ссылки
|
||||
- BRD: `docs/work-items/ORCH-098/01-brd.md`
|
||||
- TRZ: `docs/work-items/ORCH-098/02-trz.md`
|
||||
- Acceptance: `docs/work-items/ORCH-098/03-acceptance-criteria.md`
|
||||
- Data: `docs/work-items/ORCH-098/08-data-requirements.md`
|
||||
- Infra: `docs/work-items/ORCH-098/07-infra-requirements.md`
|
||||
- Risks: `docs/work-items/ORCH-098/10-tech-risks.md`
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0033-lessons-journal.md`
|
||||
- Сверено по коду: `src/serial_gate.py`, `src/coverage_gate.py`, `src/metrics.py`, `src/db.py:251,341`,
|
||||
`src/stage_engine.py:728,~1993`, `src/merge_gate.py:811,1588`, `src/agents/launcher.py:997`,
|
||||
`src/main.py` (`GET /queue`, `POST /coverage/baseline`), `src/qg/checks.py:520`.
|
||||
Reference in New Issue
Block a user