From 0677ea3a7ebd4841b61a028dd593aeb1843a88c1 Mon Sep 17 00:00:00 2001 From: Slava Date: Wed, 10 Jun 2026 09:36:18 +0300 Subject: [PATCH 01/11] docs: init ORCH-098 business request --- docs/work-items/ORCH-098/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-098/00-business-request.md diff --git a/docs/work-items/ORCH-098/00-business-request.md b/docs/work-items/ORCH-098/00-business-request.md new file mode 100644 index 0000000..e748d49 --- /dev/null +++ b/docs/work-items/ORCH-098/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: FND: машинный журнал уроков — структурированная база отклонений (топливо петли) + +Work Item ID: ORCH-098 + +## Description + +TBD -- 2.49.1 From 1dc067a00c47d2b0714841e3d4ab83c26edeeaae Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:01:47 +0300 Subject: [PATCH 02/11] analyst(ET): auto-commit from analyst run_id=573 --- docs/work-items/ORCH-098/01-brd.md | 143 +++++++++++++++ docs/work-items/ORCH-098/02-trz.md | 163 ++++++++++++++++++ .../ORCH-098/03-acceptance-criteria.md | 123 +++++++++++++ docs/work-items/ORCH-098/04-test-plan.yaml | 91 ++++++++++ 4 files changed, 520 insertions(+) create mode 100644 docs/work-items/ORCH-098/01-brd.md create mode 100644 docs/work-items/ORCH-098/02-trz.md create mode 100644 docs/work-items/ORCH-098/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-098/04-test-plan.yaml diff --git a/docs/work-items/ORCH-098/01-brd.md b/docs/work-items/ORCH-098/01-brd.md new file mode 100644 index 0000000..4d12454 --- /dev/null +++ b/docs/work-items/ORCH-098/01-brd.md @@ -0,0 +1,143 @@ +--- +work_item: ORCH-098 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-098 — FND: машинный журнал уроков (структурированная база отклонений) + +Work Item: **ORCH-098** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Оркестратор уже автономно проводит задачи через конвейер (ORCH-54), но **развивает** платформу +по-прежнему вручную связка Слава+Стрим: ловим инциденты → формулируем уроки → заводим задачи. +Уроки сегодня живут **свободным текстом** в `memory/` — они не машиночитаемы, по ним нельзя +считать паттерны, нельзя приоритизировать, нельзя автоматически предлагать улучшения. + +ORCH-098 — шаг 1 эпика саморазвития (`docs/epics/self-evolution.md`, **домен 0 «Фундамент», F2**, +ORCH-8). Это **«топливо» вертикали-двигателя** (петля самообучения 8A): формализовать свободный +текст в **машинную структурированную таблицу отклонений конвейера**. Каждый урок — запись с +полями для машинного анализа паттернов. Журнал — фундамент, на котором позже встанут +ретроспективщик (E2), приоритизатор RICE (E3) и Стрим как потребители. + +**Установленные факты-источники сигналов («уроков»)** — из памяти орка (инциденты 06–09.06) и §8A +эпика: +- Провал гейта (BLOCKED / FAILED / REQUEST_CHANGES). +- **Ручное вмешательство человека — самый ценный сигнал** (каждый ручной пинок = дыра автономности). +- Ретраи, откаты деплоя, таймауты агентов. +- Ложные срабатывания гейтов (исторический пример: substring `PASS` в `check_tests_passed`). +- «Деплой SUCCESS, а прод не работает» (урок ET-8); транзиенты (Gitea `405`, Anthropic `Overloaded`). + +**Решение Славы 10.06 (ОБЯЗАТЕЛЬНО учесть на этапе схемы):** схема журнала ДОЛЖНА **с самого +начала** нести поля для будущей **АТРИБУЦИИ** урока (иначе потом переделывать схему на живой +общей прод-БД). Атрибуция (`platform-level` / `project-level` / `both` / `unknown`), целевой +проект и целевой домен улучшения — это §8A эпика «platform-level vs project-level». При автозаписи +поля атрибуции могут быть пустыми/`unknown` (классификацию позже ставит ретроспективщик/Стрим), но +**колонки в схеме должны существовать сразу** — аддитивные, нуллабельные. + +**Связь со слоями наблюдения (§2 эпика):** деградация продукта (слой 3, урок ET-8) — один из типов +урока; журнал должен уметь его хранить с атрибуцией `platform`/`project`. + +## 2. Объём (scope) + +### В объёме +- Аддитивная идемпотентная таблица БД `lessons` для структурированных уроков со всеми полями + контекста, анализа, статуса **и атрибуции** (колонки атрибуции — сразу, нуллабельные). +- Leaf-модуль `src/lessons.py` (never-raise, kill-switch) + helper записи урока. +- **Автозапись** ≥2–3 типов отклонений из кода через best-effort точки врезки в + `stage_engine.py` / `merge_gate.py` / `launcher.py` (провал гейта/откат, HOLD, транзиент-ретрай). +- **Read-only выборка** уроков (HTTP-эндпоинт + блок в `GET /queue`) — для будущего + ретроспективщика и Стрим. +- **Ручная запись** урока (HTTP-эндпоинт / helper) — Стрим/оператор кладёт урок руками. +- Доки (CLAUDE.md / architecture README / ADR) + `CHANGELOG.md`. + +### Вне объёма +- **Анализ паттернов / ретроспективщик (E2)** — отдельная задача-потребитель журнала. +- **Приоритизатор RICE (E3)** — отдельная задача. +- **Автоматическая классификация атрибуции** — её ставит ретроспективщик/человек позже; здесь — + только колонки и возможность проставить значение руками/через update. +- **Банк идей (D4 / идеатор, E5)** — отдельный реестр, НЕ путать с журналом уроков. +- **Слой-3 детекция здоровья продукта** (мониторинг задеплоенного приложения) — отдельная + D4/D5-способность; журнал лишь умеет **хранить** такой урок, когда детектор появится. +- Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключей / любых + существующих таблиц. +- Миграция исторических уроков из `memory/` (ручной разовый импорт — вне объёма). + +## 3. Заинтересованные стороны +- **Заказчик:** Слава (требование атрибуции 10.06 — нормативно). +- **Прямой потребитель (будущее):** агент-ретроспективщик E2, приоритизатор E3, Стрим (ручной + разбор). +- **Затрагивается:** self-hosting прод-инстанс orchestrator (общая БД и очередь с enduro-trails) — + enduro **не должен быть затронут** (аддитивность, never-raise). +- **Принимает результат:** reviewer/tester конвейера + Слава. + +## 4. Бизнес-требования (BR) + +- **BR-1 — Структурированная таблица уроков.** Аддитивная, идемпотентная (`CREATE TABLE IF NOT + EXISTS`) таблица `lessons` на общей прод-БД с полями: тип отклонения; контекст + (work_item/task/стадия/агент/repo); корневая причина (если известна); предложенное улучшение + (если есть); статус (`new`/`in_progress`/`closed`/`linked`) + связанная задача; timestamp. +- **BR-2 — Поля атрибуции с самого начала.** Схема несёт **сразу** нуллабельные колонки: + `attribution` (`platform`/`project`/`both`/`unknown`), `target_repo` (кого касается: + `orchestrator`/`enduro-trails`/др.), `target_domain` (домен улучшения: + `reliability`/`quality`/`economy`/`features`/`scale`). При автозаписи допустимо пусто/`unknown`. +- **BR-3 — Автозапись ≥2–3 типов отклонений.** Из кода, best-effort, в детерминированных + choke-point: (а) провал гейта / откат на `development` (reviewer REQUEST_CHANGES, tester FAIL, + staging/deploy FAILED), (б) HOLD merge-актора / regression-guard HOLD, (в) транзиент-ретрай + (Gitea-merge `405`/`5xx`, Anthropic `Overloaded`/agent-timeout requeue). Дополнительно желательно + (г) post-deploy `DEGRADED` (урок «деплой OK / прод сломан», слой-3, ET-8) с атрибуцией. +- **BR-4 — Read-only выборка.** HTTP-эндпоинт `GET /lessons` (фильтры: тип/статус/repo/work_item, + лимит) + read-only блок `lessons` в `GET /queue` (сводка). Только чтение. +- **BR-5 — Ручная запись.** HTTP-эндпоинт `POST /lessons` (+ публичный helper) — оператор/Стрим + кладёт урок руками, в т.ч. с проставленной атрибуцией. +- **BR-6 — Обновление урока.** Возможность сменить статус / проставить атрибуцию / привязать + задачу после создания (helper/эндпоинт `POST /lessons/{id}` или поля в `POST /lessons`) — чтобы + ретроспективщик/человек позже классифицировал автозаписанный `unknown`. + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 — never-raise (критично, self-hosting).** Сбой записи/чтения урока **никогда** не роняет + и не тормозит конвейер. Любая ошибка детектора/записи → лог WARNING + продолжение основного + потока. Журнал — наблюдатель, не участник пайплайна. +- **NFR-2 — Kill-switch.** Флаг `lessons_enabled` (env `ORCH_LESSONS_ENABLED`). `False` → + автозапись и эндпоинты инертны (нулевая регрессия, поведение конвейера байт-в-байт прежнее). +- **NFR-3 — Аддитивность / изоляция enduro.** Только новая таблица + новый leaf + новые эндпоинты + + тонкие врезки. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключи / схема + существующих таблиц — **байт-в-байт не тронуты**. Общая БД: enduro-trails не затронут. +- **NFR-4 — Restart-safe / идемпотентность таблицы.** `CREATE TABLE IF NOT EXISTS` + `_ensure_column` + (паттерн `repo_freeze`/`coverage_baseline`) — безопасно на живой БД, повторный старт без эффекта. +- **NFR-5 — Лёгкость.** Запись — один `INSERT`, чтение — простые `SELECT` (общий хост впритык: + RAM 171Mi free, диск 92%). Никаких фоновых потоков/сканов. +- **NFR-6 — Схема-forward-proof.** Колонки атрибуции добавлены сразу (BR-2), чтобы не + переделывать схему на живой БД, когда появится ретроспективщик. +- **NFR-7 — Self-hosting безопасность.** Модуль только пишет/читает БД и отдаёт JSON — не + деплоит, не рестартит прод, не трогает `main`, не порождает процессы/сеть. + +## 6. Допущения и ограничения +- Журнал уроков — **исключение** из правила «наблюдатель отделён от наблюдаемого» (§2 эпика): это + историческая память петли, не realtime-мониторинг → допустимо в БД орка; запись best-effort. +- Точки автозаписи привязаны к существующим choke-point: `stage_engine._handle_qg_failure_rollbacks` + (откаты), `merge_gate` (HOLD/transient-классификатор ORCH-093), `launcher` (timeout/requeue + транзиентов). Архитектор уточняет точный набор и сигнатуры врезок. +- Набор значений `lesson_type` / `attribution` / `target_domain` — конвенция (строковые слаги), + не enum-констрейнт БД (forward-compatible; новый тип не требует миграции). +- Общая прод-БД с enduro: любое поле repo-scoped, фильтрация на уровне выборки. + +## 7. Критерии успеха +Таблица `lessons` создаётся идемпотентно на старте; автозаписаны ≥2–3 типа отклонений из реального +прогона; `GET /lessons` и `POST /lessons` работают; атрибутивные колонки присутствуют и +проставляемы; kill-switch выключает всё без регрессии; `pytest tests/ -q` зелёный; доки+CHANGELOG +обновлены. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски +- Врезка детектора в горячий путь конвейера → риск регрессии при сбое записи. Митигация: NFR-1 + never-raise + kill-switch. +- Рост таблицы со временем (автозапись на каждом откате/ретрае). Митигация: лёгкие строки; + будущая ретенция — вне объёма, отметить в `10-tech-risks.md` (архитектор). +- Недооформленная схема атрибуции → переделка на живой БД. Митигация: BR-2/NFR-6 (колонки сразу). +- Детали и архитектурные развилки (точные точки врезки, индексы, дедуп автозаписей) — задача + архитектора (`06-adr/`, `10-tech-risks.md`). diff --git a/docs/work-items/ORCH-098/02-trz.md b/docs/work-items/ORCH-098/02-trz.md new file mode 100644 index 0000000..a695ed8 --- /dev/null +++ b/docs/work-items/ORCH-098/02-trz.md @@ -0,0 +1,163 @@ +--- +work_item: ORCH-098 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-098 — FND: машинный журнал уроков + +Work Item: **ORCH-098** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование/решения (точные сигнатуры врезок, индексы, дедуп, ретенция) — задача +> архитектора (`06-adr`). + +## 1. Сводка изменения + +Ввести **машинный журнал уроков** — аддитивную таблицу `lessons` + чистый leaf-модуль +`src/lessons.py` (never-raise, kill-switch) по образцу `serial_gate.py` / `coverage_gate.py` / +`metrics.py`. Модуль несёт: helper записи урока (`record`), read-only выборку (`get_lessons`), +обновление (`update_lesson`), `snapshot()` для `GET /queue`. Автозапись ≥2–3 типов отклонений — +тонкими best-effort врезками в существующие choke-point `stage_engine.py` / `merge_gate.py` / +`launcher.py`. Два новых HTTP-эндпоинта (`GET /lessons`, `POST /lessons`) в `main.py`. Схема несёт +**сразу** нуллабельные колонки атрибуции (требование Славы 10.06). Конвейер (`STAGE_TRANSITIONS` / +`QG_CHECKS` / `check_*` / machine-verdict) — **не тронут**; enduro — не затронут. + +## 2. Задействованные модули / пути +| Путь | Действие | +|------|----------| +| `src/db.py` | изменить — `CREATE TABLE IF NOT EXISTS lessons` в `init_db()`; helper'ы `record_lesson` / `get_lessons` / `update_lesson` / `lessons_snapshot` | +| `src/lessons.py` | **создать** — leaf: `record(...)`, `get(...)`, `update(...)`, `snapshot()`, константы `LessonType`/`Attribution`/`Domain`, `applies()`, never-raise | +| `src/config.py` | изменить — флаг `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`) + опц. `lessons_query_limit_default` | +| `src/stage_engine.py` | изменить — best-effort врезка `lessons.record(...)` в `_handle_qg_failure_rollbacks` (откаты gate-fail) и в ветку post-deploy `DEGRADED` → freeze | +| `src/merge_gate.py` | изменить — best-effort врезка в HOLD/regression-guard HOLD и в транзиент-классификатор (`_classify_merge_response == "transient"` / merge-retry-исчерпан) | +| `src/agents/launcher.py` | изменить — best-effort врезка при timeout-kill / транзиент-requeue агента | +| `src/main.py` | изменить — эндпоинты `GET /lessons`, `POST /lessons` (+опц. `POST /lessons/{id}`); блок `lessons` в `GET /queue` | +| `tests/test_lessons.py` | **создать** — unit + integration (см. `04-test-plan.yaml`) | +| `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` | изменить — документация | + +## 3. Функциональные требования + +### FR-1 — Таблица `lessons` (BR-1, BR-2) +Аддитивная идемпотентная таблица в `db.init_db()` (паттерн `repo_freeze`/`coverage_baseline`): + +```sql +CREATE TABLE IF NOT EXISTS lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT, + -- тип отклонения (slug-конвенция, не enum-констрейнт) + lesson_type TEXT NOT NULL, + -- контекст + 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, + -- АТРИБУЦИЯ (BR-2, Слава 10.06) — нуллабельные, заполняются позже + attribution TEXT, -- platform|project|both|unknown + target_repo TEXT, -- кого касается (orchestrator|enduro-trails|…) + target_domain TEXT, -- reliability|quality|economy|features|scale + -- учёт + 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); +``` +Колонки атрибуции создаются **сразу** и нуллабельны (NFR-6). На уже созданной таблице новые +колонки добавляются `_ensure_column` (forward-safe). Никакого `enum`-констрейнта — значения суть +конвенция строковых слагов (forward-compatible). + +### FR-2 — Helper записи `lessons.record(...)` (BR-3, BR-5; NFR-1) +Сигнатура (уточняет архитектор), напр.: +`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`. +- При `lessons_enabled is False` → немедленный no-op (`None`), без обращения к БД. +- Оборачивает `db.record_lesson` в `try/except` → при любой ошибке `logger.warning` + `None` + (**never-raise**, NFR-1). Возвращает `id` вставленной строки при успехе. +- `source="auto"` для детекторов, `source="manual"` для ручной записи. + +### FR-3 — Автозапись отклонений (BR-3) +Минимум 2–3 типа, best-effort (каждая врезка обёрнута/делегирует в never-raise `record`): +- **FR-3a — gate-fail / rollback** — в `stage_engine._handle_qg_failure_rollbacks`: при откате на + `development` (reviewer `REQUEST_CHANGES`, tester `check_tests_passed` FAIL, staging FAILED, + deploy FAILED) → `record("gate_failure", stage=…, agent=…, work_item_id=…, repo=…, + root_cause=reason)`. Тип откатной причины → в `detail`/`root_cause`. +- **FR-3b — merge HOLD / regression-guard HOLD** — в `merge_gate` (путь HOLD `_handle_merge_verify` + / `main_regressed_alerts_total` инкремент) → `record("merge_hold", …, root_cause=…)`. +- **FR-3c — транзиент-ретрай** — в `merge_gate._classify_merge_response`-ветке `"transient"` + (Gitea `405`/`5xx`) и/или `launcher` timeout-kill / транзиент-requeue (Anthropic `Overloaded`) → + `record("transient_retry", …, detail=<код/причина>)`. +- **FR-3d (желательно) — post-deploy DEGRADED** — в ветке `stage_engine`, где post-deploy + `DEGRADED`/rollback ведёт к `set_repo_freeze` (ORCH-088/021) → `record("deploy_degraded", …, + attribution=None|"unknown", target_repo=repo)` — урок «деплой OK / прод сломан» (слой-3, ET-8), + атрибуцию проставит ретроспективщик/человек позже. + +Дедуп/частота автозаписи (чтобы не плодить дубли на ретраях) — решение архитектора (например, +ключ `work_item_id+stage+lesson_type` в окне); если не реализуется в v1 — отметить в `10-tech-risks.md`. + +### FR-4 — Read-only выборка (BR-4) +`db.get_lessons(*, lesson_type=None, status=None, repo=None, work_item_id=None, limit=N) -> +list[dict]` (параметризованный `SELECT … ORDER BY id DESC LIMIT ?`). `lessons.get(...)` — +never-raise обёртка → `[]` при ошибке. `lessons.snapshot()` — лёгкая сводка (счётчики по +типу/статусу, последние N) для `GET /queue`, never-raise → `{}`. + +### FR-5 — Ручная запись + обновление (BR-5, BR-6) +- `POST /lessons` (тело JSON) → `lessons.record(..., source="manual")`. Возвращает `{id}`. +- `POST /lessons/{id}` (или поля в `POST /lessons`) → `lessons.update(id, status=…, + attribution=…, target_repo=…, target_domain=…, related_task=…, root_cause=…, suggestion=…)` → + `db.update_lesson` (`UPDATE … SET … updated_at=datetime('now')`). Позволяет ретроспективщику/ + человеку классифицировать автозаписанный `unknown`. never-raise. + +### FR-6 — Kill-switch + изоляция (NFR-2, NFR-3) +`lessons_enabled=False` → `record`/`get`/`update`/`snapshot` инертны, эндпоинты возвращают +`{"enabled": false}` (паттерн `metrics_endpoint_enabled`), врезки no-op. Поведение конвейера — +байт-в-байт прежнее. enduro не затронут (общая БД, аддитивная таблица). + +## 4. Изменения API +Новые эндпоинты в `src/main.py` (стиль `GET /queue` / `POST /coverage/baseline`): +- **`GET /lessons`** — read-only выборка. Query: `type`, `status`, `repo`, `work_item`, `limit` + (дефолт из конфига). Ответ: `{"enabled": bool, "lessons": [ {…строка…} ]}`. Всегда `200`. +- **`POST /lessons`** — ручная запись. Тело: `lesson_type` (обяз.) + опциональные поля контекста/ + анализа/атрибуции. Ответ: `{"id": }` или `{"enabled": false}`. +- **(опц.) `POST /lessons/{id}`** — обновление статуса/атрибуции/привязки задачи. Ответ `{"ok": bool}`. +- `GET /queue` — добавить read-only ключ `"lessons": lessons.snapshot()` (рядом с `serial_gate`/ + `coverage`/`bug_fast_track`). Существующие ключи — без изменений. + +`GET /health` / `GET /status` / `GET /metrics` / прочие эндпоинты — **байт-в-байт прежние**. + +## 5. Изменения схемы БД +**Новая аддитивная таблица `lessons`** (FR-1) + два индекса, всё `IF NOT EXISTS` / `_ensure_column`. +Существующие таблицы (`tasks`/`jobs`/`agent_runs`/`events`/`job_deps`/`repo_freeze`/ +`coverage_baseline`/`tracker_messages`) — **не тронуты**. Колонки атрибуции — сразу, нуллабельные +(BR-2/NFR-6). Restart-safe, идемпотентно, безопасно на живой общей прод-БД (enduro не затронут). + +## 6. Требования к новым/изменённым QG checks +**Нет.** Журнал уроков — наблюдатель, **не** Quality Gate. `QG_CHECKS` / `check_*` / +machine-verdict-ключи (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/ +`coverage_status:`) — байт-в-байт не тронуты. Журнал не влияет на продвижение по стадиям. + +## 7. Совместимость / регресс +- **Kill-switch** `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`): `False` → полная + инертность, нулевая регрессия. +- **never-raise** на всех публичных функциях и врезках (NFR-1) — сбой журнала не роняет конвейер. +- **Аддитивно**: только новая таблица + leaf + эндпоинты + тонкие врезки; ничего существующего не + переписывается. +- **Изоляция enduro**: общая БД, новая таблица; репо-скоуп через поле/фильтр выборки. +- **Обратимость**: выключение флага возвращает прод к доресурсному поведению мгновенно. +- **Self-hosting безопасность** (NFR-7): модуль не деплоит/не рестартит прод/не трогает `main`/без + процессов/сети. +- **Артефакты pipeline:** задача создаёт/обновляет стандартный пакет (`01`–`04` + `06-adr` от + архитектора, `12`/`13`/`14`/`15`/`17`/`18` по ходу конвейера). Сам журнал — БД-сущность, не + номерной артефакт. diff --git a/docs/work-items/ORCH-098/03-acceptance-criteria.md b/docs/work-items/ORCH-098/03-acceptance-criteria.md new file mode 100644 index 0000000..0a1a2fd --- /dev/null +++ b/docs/work-items/ORCH-098/03-acceptance-criteria.md @@ -0,0 +1,123 @@ +--- +work_item: ORCH-098 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-098 — FND: машинный журнал уроков + +Work Item: **ORCH-098** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам. + +--- + +## AC-1 — Аддитивная таблица уроков + +**Условие:** `db.init_db()` создаёт таблицу `lessons` идемпотентно. +- **PASS:** в `src/db.py` есть `CREATE TABLE IF NOT EXISTS lessons (...)` со всеми полями + (`lesson_type`, контекст `work_item_id/task_id/stage/agent/repo`, `root_cause`, `suggestion`, + `status`+`related_task`, `created_at`); повторный `init_db()` не падает и не дублирует; таблица + создаётся на общей прод-БД без изменения существующих таблиц. +- **FAIL:** таблицы нет / создаётся не идемпотентно / отсутствует любое поле из BR-1 / меняется + схема существующей таблицы. + +--- + +## AC-2 — Поля атрибуции присутствуют с самого начала + +**Условие:** схема `lessons` несёт нуллабельные колонки атрибуции (требование Славы 10.06). +- **PASS:** колонки `attribution` (`platform`/`project`/`both`/`unknown`), `target_repo`, + `target_domain` существуют сразу, нуллабельны, допускают пустое/`unknown` при автозаписи и + проставляются позже через update. +- **FAIL:** хотя бы одной из трёх колонок нет в исходной схеме / колонка `NOT NULL` без дефолта / + атрибуцию нельзя проставить после создания записи. + +--- + +## AC-3 — Автозапись ≥2–3 типов отклонений + +**Условие:** из кода автоматически (best-effort, `source="auto"`) пишутся минимум 2–3 типа уроков. +- **PASS:** есть врезки `lessons.record(...)` минимум в двух-трёх точках из: + `stage_engine._handle_qg_failure_rollbacks` (gate-fail/откат), `merge_gate` (HOLD/transient), + `launcher` (timeout/transient-requeue); интеграционный тест подтверждает появление строки в + `lessons` после смоделированного отклонения. +- **FAIL:** автозаписи нет / реализован <2 типов / врезка может бросить исключение в горячий путь. + +--- + +## AC-4 — Read-only выборка + +**Условие:** уроки можно прочитать через эндпоинт и сводку в `GET /queue`. +- **PASS:** `GET /lessons` возвращает `200` с массивом уроков, поддерживает фильтры + (type/status/repo/work_item/limit); `GET /queue` содержит read-only блок `lessons`; ни один + путь чтения не мутирует данные. +- **FAIL:** эндпоинта нет / не фильтрует / чтение мутирует данные / блока в `/queue` нет. + +--- + +## AC-5 — Ручная запись и обновление + +**Условие:** оператор/Стрим кладёт урок руками и может его доклассифицировать. +- **PASS:** `POST /lessons` создаёт урок (`source="manual"`, можно задать атрибуцию); обновление + (`POST /lessons/{id}` или поля) меняет `status`/`attribution`/`target_*`/`related_task` и + стампит `updated_at`. +- **FAIL:** ручной записи нет / нельзя проставить атрибуцию / нельзя обновить автозаписанный урок. + +--- + +## AC-6 — never-raise (сбой журнала не роняет конвейер) + +**Условие:** любая ошибка записи/чтения урока изолирована от пайплайна. +- **PASS:** все публичные функции `src/lessons.py` и все врезки обёрнуты так, что исключение БД/ + любого источника → `logger.warning` + безопасный дефолт (`None`/`[]`/`{}`); юнит-тест с + замоканной падающей БД подтверждает, что вызывающий код (откат/HOLD/retry) не падает. +- **FAIL:** исключение из журнала пробивается в `stage_engine`/`merge_gate`/`launcher`/эндпоинт. + +--- + +## AC-7 — Kill-switch и нулевая регрессия + +**Условие:** `lessons_enabled=False` делает функционал инертным. +- **PASS:** при `False` `record`/`get`/`update`/`snapshot` — no-op (без обращения к БД), эндпоинты + отдают `{"enabled": false}`, врезки не пишут; поведение конвейера и `GET /queue` (помимо нового + блока) — байт-в-байт прежнее; enduro-trails не затронут. +- **FAIL:** при `False` журнал что-то пишет/ломает / меняется поведение конвейера / затронут enduro. + +--- + +## AC-8 — Инварианты конвейера не тронуты + +**Условие:** изменение не касается машины стадий и гейтов. +- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, функции `check_*`, machine-verdict-ключи и + схема существующих таблиц — **диффом не затронуты**; журнал не влияет на продвижение по стадиям. +- **FAIL:** изменён любой из перечисленных артефактов / журнал участвует в решении гейта. + +--- + +## AC-9 — Тесты, документация, CHANGELOG + +**Условие:** изменение проверено и задокументировано. +- **PASS:** `pytest tests/ -q` зелёный (включая новый `tests/test_lessons.py` с unit+integration); + обновлены `CLAUDE.md` + `docs/architecture/README.md`; в задаче есть `06-adr/` (архитектор); + `CHANGELOG.md` дополнен. +- **FAIL:** тесты падают / нет покрытия новой логики / документация или CHANGELOG не обновлены. + +--- + +## Сводная матрица AC ↔ FR/BR +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1 | +| AC-2 | BR-2 / FR-1 / NFR-6 | +| AC-3 | BR-3 / FR-2 / FR-3 | +| AC-4 | BR-4 / FR-4 | +| AC-5 | BR-5 / BR-6 / FR-5 | +| AC-6 | NFR-1 / FR-2 | +| AC-7 | NFR-2 / NFR-3 / FR-6 | +| AC-8 | NFR-3 / FR-6 | +| AC-9 | NFR-1…NFR-7 (верификация) | diff --git a/docs/work-items/ORCH-098/04-test-plan.yaml b/docs/work-items/ORCH-098/04-test-plan.yaml new file mode 100644 index 0000000..03e03ec --- /dev/null +++ b/docs/work-items/ORCH-098/04-test-plan.yaml @@ -0,0 +1,91 @@ +work_item: ORCH-098 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +title: "Журнал уроков: таблица, автозапись отклонений, выборка, ручная запись, never-raise" +framework: pytest +scope: > + Покрывается: создание аддитивной таблицы lessons (идемпотентность, поля атрибуции), + helper записи record(), автозапись из choke-point (gate-fail/HOLD/transient), read-only + выборка get_lessons + snapshot, ручная запись/обновление, kill-switch, never-raise. + Вне покрытия: ретроспективщик (E2), приоритизатор (E3), автоклассификация атрибуции, + слой-3 детекция здоровья продукта. +notes: > + Тесты используют изолированную временную SQLite-БД (фикстура init_db во временном файле). + Полный регресс tests/ должен оставаться зелёным. Self-hosting: журнал never-raise — ни один + тест не должен показать, что сбой записи урока роняет конвейер. + +tests: + - id: TC-01 + type: unit + description: "init_db() создаёт таблицу lessons идемпотентно (двойной вызов не падает, нет дублей); присутствуют все поля BR-1." + module: tests/test_lessons.py + expected: PASS + + - id: TC-02 + type: unit + description: "Схема lessons несёт нуллабельные колонки атрибуции attribution/target_repo/target_domain; запись без них проходит (NULL/unknown), update проставляет их позже." + module: tests/test_lessons.py + expected: PASS + + - id: TC-03 + type: unit + description: "lessons.record() вставляет строку с переданными полями (source=auto/manual), возвращает id; created_at заполняется." + module: tests/test_lessons.py + expected: PASS + + - id: TC-04 + type: unit + description: "never-raise: при замоканной падающей БД record/get/update/snapshot возвращают None/[]/{} и не бросают исключение (logger.warning)." + module: tests/test_lessons.py + expected: PASS + + - id: TC-05 + type: unit + description: "kill-switch: при lessons_enabled=False record/get/update/snapshot инертны (no-op, без обращения к БД)." + module: tests/test_lessons.py + expected: PASS + + - id: TC-06 + type: unit + description: "get_lessons фильтрует по type/status/repo/work_item и соблюдает limit; порядок ORDER BY id DESC." + module: tests/test_lessons.py + expected: PASS + + - id: TC-07 + type: unit + description: "update_lesson меняет status/attribution/target_*/related_task и стампит updated_at; несуществующий id безопасен." + module: tests/test_lessons.py + expected: PASS + + - id: TC-08 + type: integration + description: "Автозапись gate-fail: смоделированный откат на development в _handle_qg_failure_rollbacks создаёт строку lessons type=gate_failure с контекстом (stage/agent/work_item/repo)." + module: tests/test_lessons.py + expected: PASS + + - id: TC-09 + type: integration + description: "Автозапись transient/HOLD: транзиент-ветка merge_gate (или timeout/requeue launcher) пишет урок type=transient_retry/merge_hold; сбой записи не ломает основной путь (never-raise в горячем пути)." + module: tests/test_lessons.py + expected: PASS + + - id: TC-10 + type: integration + description: "GET /lessons возвращает 200 с массивом и фильтрами; GET /queue содержит read-only блок lessons; чтение не мутирует данные." + module: tests/test_lessons.py + expected: PASS + + - id: TC-11 + type: integration + description: "POST /lessons создаёт ручной урок (source=manual, с атрибуцией); POST /lessons/{id} обновляет его; при lessons_enabled=False эндпоинты отдают {enabled:false}." + module: tests/test_lessons.py + expected: PASS + + - id: TC-12 + type: unit + description: "Инварианты конвейера не тронуты: STAGE_TRANSITIONS/QG_CHECKS/machine-verdict-ключи неизменны (структурный анти-регресс по составу реестра)." + module: tests/test_lessons.py + expected: PASS -- 2.49.1 From 9f62df02ebb60da5d85b4f157bf346f73571fc71 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:10:08 +0300 Subject: [PATCH 03/11] architect(ET): auto-commit from architect run_id=574 --- docs/architecture/README.md | 1 + .../adr/adr-0033-lessons-journal.md | 92 +++++++ .../06-adr/ADR-001-lessons-journal.md | 244 ++++++++++++++++++ .../ORCH-098/07-infra-requirements.md | 45 ++++ .../ORCH-098/08-data-requirements.md | 76 ++++++ docs/work-items/ORCH-098/10-tech-risks.md | 39 +++ 6 files changed, 497 insertions(+) create mode 100644 docs/architecture/adr/adr-0033-lessons-journal.md create mode 100644 docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md create mode 100644 docs/work-items/ORCH-098/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-098/08-data-requirements.md create mode 100644 docs/work-items/ORCH-098/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a54c24c..a80007b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -20,6 +20,7 @@ - **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` неизменна (обратная совместимость). - **FS ownership detect** (`src/fs_normalize.py`, ORCH-057 — [adr-0031](adr/adr-0031-legacy-ownership-normalization.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`preflight`), закрывает пробел ORCH-040: при миграции на `user: "1000:1000"` legacy `root:root` файлы в `/repos` ломали создание worktree под uid 1000 (`ensure_worktree` → сырой `fatal: … Permission denied`, агент не стартовал). Три слоя: (1) **D1** — `src/git_worktree.py::ensure_worktree` классифицирует класс «нет прав» (`Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/`EPERM`) и поднимает actionable `RuntimeError` с причиной + лечащей командой (не-прав-ошибки сохраняют прежний контракт — меняется только формулировка, не факт сбоя); (2) **D2** — `scan_ownership(roots, target_uid=os.getuid())` обходит `/repos/_wt`, `/.git/{objects,worktrees}`, `data/runs` с ранним выходом при первом `st_uid != target_uid` + TTL-кэш; (3) **D3** — best-effort вызов на старте `main.lifespan` → WARNING + Telegram при mismatch (claim **НЕ** блокируется — внятный ранний отказ даёт D1 в точке launch, знающей repo; preflight-блок отвергнут как repo-слепой → регресс enduro). Опц. `normalize()` chown'ит только при `CAP_CHOWN` (под uid 1000 — no-op; init-контейнер/root-entrypoint отвергнуты — реинтродукция root-контекста + self-deploy compose). Фактическая нормализация = **операторская процедура** под root на хосте (`INFRA.md` «Миграция uid»). Условность `applies(repo)` first: `fs_normalize_enabled` (kill-switch) + `fs_normalize_repos` (CSV, пусто → self-hosting only). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST /fs-normalize/check`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`. - **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`/схема БД — не тронуты. +- **Lessons journal** (`src/lessons.py` + таблица `lessons`, ORCH-098 — design, [adr-0034](adr/adr-0034-lessons-journal.md)) — машинный журнал уроков (структурированная база отклонений конвейера); шаг 1 эпика саморазвития (домен 0 «Фундамент», F2; топливо петли самообучения 8A), фундамент для будущих ретроспективщика (E2)/приоритизатора RICE (E3)/Стрим. Чистый **observer-leaf** (never-raise, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/`update()`/`snapshot()`. **Аддитивная идемпотентная таблица `lessons`** (`CREATE TABLE IF NOT EXISTS` в `init_db()`, restart-safe) с полями контекста (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализа (`root_cause`/`suggestion`), статуса (`status`/`related_task`) и **атрибуции — сразу и нуллабельно** (`attribution`/`target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже ретроспективщиком/человеком) + `source`/`detail`; без `enum`-констрейнтов (слаги forward-compatible). **Автозапись 4 типов** (`source="auto"`, best-effort, дедуп в окне; `transient_retry` — только на исчерпании бюджета ретраев) тонкими врезками: `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`), `merge_hold` (`merge_gate._handle_merge_verify` HOLD), `transient_retry` (merge-retry/launcher transient budget-exhaustion), `deploy_degraded` (post-deploy `DEGRADED → set_repo_freeze`, урок слоя-3 «деплой OK / прод сломан», ET-8). Эндпоинты `GET /lessons` (read-only, фильтры), `POST /lessons` (ручная запись), `POST /lessons/{id}` (update/доклассификация), + read-only ключ `lessons` в `GET /queue`. **Расхождение с гейт-шаблоном:** журнал observer-only → **НЕ скоупится по репо** (kill-switch `lessons_enabled` only, без `lessons_repos`); репо-разрез — на выборке (`repo`-колонка/фильтр), enduro не затронут (общая БД, аддитивная таблица). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц — байт-в-байт не тронуты (журнал не участвует в решении гейта). Kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`). Детали — `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`. - **Sidecar-watchdog F1b** (`watchdog/` + сервис `orchestrator-watchdog`, ORCH-100 — [adr-0033](adr/adr-0033-sidecar-watchdog.md)) — **мозг мониторинга в ОТДЕЛЬНОМ контейнере** (наблюдатель отделён от наблюдаемого, C-1): код в репо орка (`watchdog/`), рантайм — свой образ (`watchdog/Dockerfile`, `python:3.12-slim`, **stdlib-only**) + сервис в `docker-compose.yml` (`network_mode: host`, read-only `docker.sock`, `mem_limit: 128m`). На каждом тике собирает 4 источника: `GET /metrics` орка (F1a/ORCH-099), хост (диск/inode/память/CPU, stdlib), статусы контейнеров через read-only `docker.sock` (GET-only, без `docker` SDK), пинг Plane/Gitea/Anthropic. Каждый сигнал → **обобщённая чистая** `decide(signal_active, prev, now, cooldown)` (генерализация `disk_watchdog.decide_action`, per-signal in-memory `AlertState`) → алерт в **собственный** Telegram-канал sidecar (`WATCHDOG_TG_*`, **НЕ** импорт `src/notifications.py`). Особый сигнал `orch_down` — `/metrics` не отвечает (наблюдатель жив, наблюдаемый лёг). Диск: штатные 85% остаются за `disk_watchdog` (ORCH-063, нулевой дубль), sidecar — `orch_down` + opt-in потолок 97% (default off). never-raise, kill-switch `WATCHDOG_ENABLED`, строго read-only к наблюдаемому; `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД орка — не тронуты. Подробнее ниже (§ «Sidecar-watchdog F1b»). Детали — `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`. ## Сырьё-эндпоинт `/metrics` для sidecar (ORCH-099 — design) diff --git a/docs/architecture/adr/adr-0033-lessons-journal.md b/docs/architecture/adr/adr-0033-lessons-journal.md new file mode 100644 index 0000000..25ac65f --- /dev/null +++ b/docs/architecture/adr/adr-0033-lessons-journal.md @@ -0,0 +1,92 @@ +--- +work_item: ORCH-098 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# adr-0033: Машинный журнал уроков — таблица `lessons` + observer-leaf (ORCH-098) + +## Статус +Proposed + +## Контекст + +Оркестратор автономно ведёт задачи по конвейеру (ORCH-54), но **развивается** вручную: инциденты → +уроки → задачи. Уроки живут свободным текстом в `memory/` — не машиночитаемы: нельзя считать +паттерны, приоритизировать, предлагать улучшения. ORCH-098 — шаг 1 эпика саморазвития (домен 0 +«Фундамент», F2): «топливо» петли самообучения 8A. Нужна **структурированная таблица отклонений +конвейера**, на которой позже встанут ретроспективщик (E2), приоритизатор RICE (E3) и Стрим. + +Нормативное требование Славы (10.06): схема ДОЛЖНА **сразу** нести поля **атрибуции** урока +(`platform`/`project`/`both`/`unknown` + целевой репо + домен улучшения), иначе позже придётся +переделывать схему на живой общей прод-БД. + +**Кросс-каттинговость** (почему сквозной ADR): новый компонент `src/lessons.py` + аддитивная +таблица на **общей прод-БД** (self-hosting, разделяемой с enduro-trails) + врезки автозаписи в +несколько горячих choke-point'ов (`stage_engine`/`merge_gate`/`launcher`) + новый раздел контракта +`GET /queue`. Фундамент для будущих задач-потребителей → регистрируется глобально. + +## Решение + +Журнал уроков — **observer (наблюдатель), НЕ Quality Gate**. Аддитивная таблица + чистый leaf, +по образцу `serial_gate`/`coverage_gate`/`metrics`/`bug_fast_track`. + +1. **Таблица `lessons`** (`db.init_db()`, `CREATE TABLE IF NOT EXISTS` + 3 индекса, идемпотентно, + restart-safe) — поля контекста (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализа + (`root_cause`/`suggestion`), статуса (`status`/`related_task`), **атрибуции сразу и нуллабельно** + (`attribution`/`target_repo`/`target_domain`) + `source`/`detail`. Без `enum`-констрейнтов + (слаги forward-compatible). Будущие колонки — `_ensure_column`. + +2. **Leaf `src/lessons.py`** (never-raise, импортирует только `config`+`db`): `record()` / `get()` / + `update()` / `snapshot()`. **Расхождение с гейт-шаблоном: журнал НЕ скоупится по репо** — он + observer-only и не *действует* ни на один репо; единственный регулятор — глобальный kill-switch + `lessons_enabled`. Запись урока про enduro ценна и **не затрагивает** пайплайн enduro (чистая + память орка); репо-разрез — на выборке (`repo`-колонка/фильтр). + +3. **Автозапись 4 типов** (`source="auto"`, best-effort, дедуп в окне; `transient_retry` — только на + исчерпании бюджета ретраев): `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`), + `merge_hold` (`merge_gate._handle_merge_verify` HOLD), `transient_retry` (merge-retry/launcher + transient budget-exhaustion), `deploy_degraded` (post-deploy `DEGRADED → set_repo_freeze`, урок + слоя-3 «деплой OK / прод сломан», ET-8). Каждая врезка — одиночный вызов в защитном `try/except`. + +4. **Эндпоинты** `GET /lessons` (read-only, фильтры), `POST /lessons` (ручная запись, + `source="manual"`), `POST /lessons/{id}` (update — доклассификация `unknown`), + read-only ключ + `"lessons": snapshot()` в `GET /queue`. При выключенном флаге → `{"enabled": false}`. + +**Инвариант (нерушимый):** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключи +(`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`) / +схемы существующих таблиц — **байт-в-байт не тронуты**. Журнал не влияет на продвижение по стадиям. + +## Композиция с существующими механизмами +- **Self-hosting (общая БД):** аддитивная таблица; enduro не затронут (NFR-3). +- **serial-gate (ORCH-088) / post-deploy (ORCH-021):** детектор `deploy_degraded` врезан рядом с + `set_repo_freeze`, не меняя freeze-логику. +- **merge-gate (ORCH-043/071/093):** `merge_hold`/`transient_retry` читают исход актора, не меняя + классификатор/ретрай. +- **metrics (ORCH-099):** журнал — историческая память петли (best-effort запись), `/metrics` — + realtime-сырьё для sidecar; разные роли, оба observer-only. + +## Условность и откат +- Флаг `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`; kill-switch) + + `lessons_dedup_window_s` / `lessons_query_limit_default`. `False` → полная инертность, нулевая + регрессия, конвейер байт-в-байт прежний. +- **never-raise** на всех публичных функциях и врезках (NFR-1) — сбой журнала не роняет конвейер. +- Откат — флаг в `false` (мгновенно) или revert диффа; таблица не касается существующих. + +## Последствия +- **+** Машиночитаемые уроки — фундамент E2/E3/Стрим; атрибуция forward-proof (без передела живой БД). +- **+** Нулевая регрессия; проверенный additive-observer-leaf шаблон → низкий риск; enduro изолирован. +- **−** Рост таблицы (митигейшн: лёгкие строки + дедуп + budget-exhaustion; ретенция — будущее). +- **−** Дедуп-запрос в `record()` (один indexed-SELECT, только `auto`). + +## Ссылки +- Локальный ADR: `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md` +- BRD/TRZ/AC: `docs/work-items/ORCH-098/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md` +- Data/Infra/Risks: `docs/work-items/ORCH-098/08-data-requirements.md`, `07-infra-requirements.md`, + `10-tech-risks.md` +- Эпик: `docs/epics/self-evolution.md` (домен 0 «Фундамент», F2; петля 8A) +- Сверено по коду: `src/serial_gate.py`, `src/coverage_gate.py`, `src/db.py`, `src/stage_engine.py`, + `src/merge_gate.py`, `src/agents/launcher.py`, `src/main.py`, `src/qg/checks.py`. diff --git a/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md b/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md new file mode 100644 index 0000000..2b20c91 --- /dev/null +++ b/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md @@ -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", "", + "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', ?) -- '- 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": }`. +- **`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`. diff --git a/docs/work-items/ORCH-098/07-infra-requirements.md b/docs/work-items/ORCH-098/07-infra-requirements.md new file mode 100644 index 0000000..9251083 --- /dev/null +++ b/docs/work-items/ORCH-098/07-infra-requirements.md @@ -0,0 +1,45 @@ +--- +work_item: ORCH-098 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-098 — машинный журнал уроков `lessons` + +Work Item: **ORCH-098** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable. Топология **не меняется**; файл создан для аудитопригодности (новая env-переменная). + +## I-1. Топология / окружения +**N/A.** Новых контейнеров/портов/сети/томов нет. Таблица `lessons` живёт в существующей общей +SQLite-БД (тот же том `./data`), эндпоинты обслуживаются текущим процессом `orchestrator` (8500) / +`orchestrator-staging` (8501). Принцип «всё в Docker на одном сервере mva154» — соблюдён. + +## I-2. Переменные окружения / секреты +Новые env (pydantic `BaseSettings`, авто-биндинг `ORCH_*`), все с безопасными дефолтами: + +| Env | Дефолт | Назначение | +|---|---|---| +| `ORCH_LESSONS_ENABLED` | `true` | kill-switch журнала (NFR-2); `false` → полная инертность | +| `ORCH_LESSONS_DEDUP_WINDOW_S` | `3600` | окно дедупа автозаписи (ADR-001 D4) | +| `ORCH_LESSONS_QUERY_LIMIT_DEFAULT` | `100` | дефолтный `limit` для `GET /lessons` | + +**`lessons_repos` СОЗНАТЕЛЬНО не вводится** — журнал observer-only и не скоупится по репо +(ADR-001 D2). Секретов нет. `.env.example` дополнить тремя ключами для документируемости (значения — +дефолтные, не секреты). + +## I-3. Деплой / рестарт +- Изменение применяется штатным конвейером: **обязательный staging-гейт (8501) перед прод-деплоем** + орка (self-hosting инвариант). Прод-контейнер **не рестартить вне процедуры деплоя стадии** + `deploy`/`Confirm Deploy` (ORCH-059) — конвейер всех проектов встанет. +- Таблица `lessons` создаётся идемпотентно при старте (`init_db()`) — на первом штатном запуске + нового образа, **без отдельной ручной миграции** (restart-safe, NFR-4). На живой БД enduro не + затронут. +- Откат — `ORCH_LESSONS_ENABLED=false` (мгновенная инертность) либо revert образа. + +## I-4. CI/CD +**Без изменений** в `.gitea/workflows/`. Новые тесты `tests/test_lessons.py` исполняются штатным +шагом `pytest tests/ -q`. Новых системных/pip-зависимостей нет (raw SQL на stdlib `sqlite3`). diff --git a/docs/work-items/ORCH-098/08-data-requirements.md b/docs/work-items/ORCH-098/08-data-requirements.md new file mode 100644 index 0000000..857e3f8 --- /dev/null +++ b/docs/work-items/ORCH-098/08-data-requirements.md @@ -0,0 +1,76 @@ +--- +work_item: ORCH-098 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-098 — машинный журнал уроков `lessons` + +Work Item: **ORCH-098** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable: задача **добавляет** одну таблицу на общую прод-БД. Схемы существующих таблиц — +> не затрагиваются. + +## Изменения схемы БД + +**Новая аддитивная таблица `lessons`** + три индекса, создаются идемпотентно в `db.init_db()` +(`CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`), restart-safe (паттерн `repo_freeze`, +`coverage_baseline`). На уже существующей таблице новые/будущие колонки добавляются через +`_ensure_column(conn, "lessons", "", "")` (`src/db.py:341`) — forward-safe, без миграции +данных. DDL — см. ADR-001 D1. + +Существующие таблицы (`tasks`/`jobs`/`agent_runs`/`events`/`job_deps`/`repo_freeze`/ +`coverage_baseline`/`tracker_messages`) — **байт-в-байт не тронуты** (NFR-3, AC-8). + +## Новые/изменённые сущности + +Сущность **`lesson`** — одна запись структурированного отклонения конвейера. Колонки: + +| Колонка | Тип | Null | Назначение | +|---|---|---|---| +| `id` | INTEGER PK AUTOINCREMENT | — | суррогатный ключ | +| `created_at` | TEXT `DEFAULT datetime('now')` | NOT NULL | момент записи | +| `updated_at` | TEXT | NULL | момент последнего `update` | +| `lesson_type` | TEXT | NOT NULL | slug-тип (`gate_failure`/`merge_hold`/`transient_retry`/`deploy_degraded`/…) | +| `work_item_id` | TEXT | NULL | контекст: задача (`ORCH-NNN`/`ET-NNN`) | +| `task_id` | INTEGER | NULL | контекст: внутренний id задачи | +| `stage` | TEXT | NULL | контекст: стадия конвейера | +| `agent` | TEXT | NULL | контекст: агент-роль | +| `repo` | TEXT | NULL | контекст: репозиторий, **разрез выборки** | +| `root_cause` | TEXT | NULL | анализ: корневая причина (если известна) | +| `suggestion` | TEXT | NULL | анализ: предложенное улучшение (если есть) | +| `status` | TEXT `DEFAULT 'new'` | NOT NULL | `new`/`in_progress`/`closed`/`linked` | +| `related_task` | TEXT | NULL | связанная заведённая задача | +| `attribution` | TEXT | **NULL** | **АТРИБУЦИЯ:** `platform`/`project`/`both`/`unknown` | +| `target_repo` | TEXT | **NULL** | **АТРИБУЦИЯ:** кого касается улучшение | +| `target_domain` | TEXT | **NULL** | **АТРИБУЦИЯ:** `reliability`/`quality`/`economy`/`features`/`scale` | +| `source` | TEXT | NULL | `auto` (детектор) / `manual` (оператор/Стрим) | +| `detail` | TEXT | NULL | свободный JSON/текст — payload детектора | + +**Инварианты данных:** +- Три колонки **атрибуции** (`attribution`/`target_repo`/`target_domain`) присутствуют в исходной + схеме, **нуллабельны** (требование Славы 10.06, NFR-6, AC-2) — при автозаписи допустимо + пусто/`unknown`; проставляются позже через `update` (AC-5). +- **Без `enum`/`CHECK`-констрейнтов** — значения `lesson_type`/`attribution`/`target_domain` суть + конвенция строковых слагов (forward-compatible: новый тип не требует миграции). +- Индексы: `idx_lessons_type_status (lesson_type, status)` — выборка/snapshot; `idx_lessons_repo + (repo)` — репо-разрез; `idx_lessons_wi_type (work_item_id, lesson_type)` — дедуп автозаписи + (ADR-001 D4). + +## Совместимость данных / миграции + +- **Аддитивно / идемпотентно / restart-safe:** только новая таблица + индексы; повторный `init_db()` + не падает и не дублирует (NFR-4). +- **Общая прод-БД (self-hosting):** таблица создаётся на том же файле БД, что обслуживает + orchestrator и enduro-trails. Уроки про любой репо хранятся в одной таблице; **изоляция enduro** — + таблица аддитивна и не участвует в пайплайне enduro (NFR-3); репо-разрез — поле `repo` + фильтр + выборки (ADR-001 D2). +- **Объём строки** — короткие текстовые поля; `detail` — компактный payload. Запись — один `INSERT`, + чтение — простой параметризованный `SELECT … ORDER BY id DESC LIMIT ?` (NFR-5; общий хост впритык: + RAM/диск). +- **Ретенция / архивация** — вне объёма v1; тренд роста и будущая стратегия — `10-tech-risks.md` + (TR-2). +- **Миграция исторических уроков из `memory/`** — вне объёма (BRD §2). diff --git a/docs/work-items/ORCH-098/10-tech-risks.md b/docs/work-items/ORCH-098/10-tech-risks.md new file mode 100644 index 0000000..8694c7c --- /dev/null +++ b/docs/work-items/ORCH-098/10-tech-risks.md @@ -0,0 +1,39 @@ +--- +work_item: ORCH-098 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-098 — машинный журнал уроков `lessons` + +Work Item: **ORCH-098** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | Врезка детектора в горячий путь конвейера (`stage_engine`/`merge_gate`/`launcher`) бросает исключение → регрессия пайплайна на self-hosting прод-инстансе (встанет конвейер всех проектов, в т.ч. enduro). | Низ. | Выс. | **NFR-1 never-raise**: `lessons.record` полностью self-contained `try/except → None`; каждая врезка дополнительно обёрнута защитным `try/except` (паттерн post-deploy-freeze, `stage_engine.py:~1993`), ловит даже ошибку импорта. **NFR-2 kill-switch** `ORCH_LESSONS_ENABLED=false` → no-op. Юнит-тест с замоканной падающей БД (AC-6). | +| TR-2 | Неограниченный рост таблицы `lessons` (автозапись на каждом откате/HOLD/деградации) на впритык-хосте (диск 92%). | Сред. | Низ. | Лёгкие строки (короткий текст); **дедуп D4** (один indexed-SELECT в окне) + **`transient_retry` только на budget-exhaustion** гасят флуд в источнике. Ретенция/архивация — отдельная будущая задача (вне объёма v1); тренд наблюдаем через `snapshot()` в `GET /queue`. | +| TR-3 | Недооформленная схема атрибуции → переделка схемы на живой общей прод-БД, когда появится ретроспировщик (E2). | Низ. | Сред. | **BR-2/NFR-6**: три нуллабельные колонки атрибуции (`attribution`/`target_repo`/`target_domain`) в схеме **сразу**; `update`/`POST /lessons/{id}` позволяет доклассифицировать `unknown` позже без миграции. Слаги без `enum`-констрейнта → новые значения не требуют DDL. | +| TR-4 | Дубли автозаписи на ретраях/повторных откатах искажают будущий pattern-анализ. | Сред. | Низ. | **Дедуп D4** для `source="auto"`: indexed `SELECT` по `idx_lessons_wi_type` в окне `ORCH_LESSONS_DEDUP_WINDOW_S` перед `INSERT`. `manual` дедуп не проходит. Если в реальном прогоне дедуп окажется слишком строгим/слабым — окно конфигурируемо без передеплоя логики. | +| TR-5 | Случайное касание инвариантов конвейера (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц) при врезках. | Низ. | Выс. | Врезки — строго аддитивные одиночные вызовы; **AC-8** требует «диффом не затронуты». Reviewer проверяет дифф перечисленных артефактов. Журнал не участвует в решении гейта (FR-6). | +| TR-6 | Эндпоинт `POST /lessons`/`/lessons/{id}` как непреднамеренный мутатор/вектор (запись в прод-БД без аутентификации). | Низ. | Сред. | Пишет **только** в аддитивную таблицу `lessons` (не трогает `tasks`/`jobs`/гейты); never-raise; `enabled:false` при выключенном флаге. Тот же уровень доступа, что у существующего `POST /coverage/baseline`. Дальнейшее ужесточение доступа — общая инфра-тема, вне объёма ORCH-098. | + +## Сводный вывод + +Доминирующий класс рисков — **изоляция наблюдателя от горячего пути конвейера на self-hosting +прод-инстансе** (TR-1, TR-5): высокое влияние при низкой вероятности, полностью покрыто +проверенной связкой *never-raise + kill-switch + чистая аддитивность*, идентичной уже работающим +leaf'ам (`serial_gate`/`coverage_gate`/`metrics`/`bug_fast_track`). Вторичный класс — **рост/шум +данных** (TR-2/TR-4): низкое влияние, смягчён лёгкими строками, дедупом и budget-exhaustion-записью; +ретенция вынесена в будущее. + +**Эскалация не требуется.** Несмотря на формально «новый компонент + новая таблица», изменение +следует устоявшемуся **additive-observer-leaf** шаблону, **не трогает машину стадий, гейты и схемы +существующих таблиц**, полностью обратимо флагом → метка `arch:major-change` **не выставляется**, +возврат в анализ (`back-to:analysis`) не нужен. Остаточный риск для прод-конвейера — **низкий**. -- 2.49.1 From 7d21625d84a36edaf72c62946acf550e0ffa3026 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:24:40 +0300 Subject: [PATCH 04/11] =?UTF-8?q?feat(lessons):=20machine=20lessons-journa?= =?UTF-8?q?l=20=E2=80=94=20additive=20table=20+=20observer=20leaf=20(ORCH-?= =?UTF-8?q?098)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 ("Foundation", F2) of the self-improvement epic: formalise free-text "lessons" from memory/ into a machine-readable `lessons` table — the foundation for the future retrospective agent (E2), the RICE prioritiser (E3) and Стрим. - src/lessons.py: pure never-raise observer leaf (record/get/update/snapshot), kill-switch only, NO repo scope (observer-only; records about any repo incl. enduro; repo cut on the read side). Slug-convention constants. - src/db.py: additive idempotent `lessons` table in init_db() (+3 indexes); nullable attribution columns from the start (NFR-6, _ensure_column forward-safe); helpers record_lesson/get_lessons/update_lesson/lessons_snapshot/ lessons_recent_dup_exists (auto-dedup window). - 4 auto-detectors (best-effort, source="auto", deduped): gate_failure (_handle_qg_failure_rollbacks), merge_hold (_handle_merge_verify HOLD), transient_retry (launcher._finalize_transient budget-exhaustion), deploy_degraded (post-deploy DEGRADED -> set_repo_freeze). - src/main.py: GET /lessons, POST /lessons, POST /lessons/{id} + read-only `lessons` block in GET /queue; off-switch -> {"enabled": false}. - src/config.py: lessons_enabled / lessons_query_limit_default / lessons_dedup_window_s. - tests/test_lessons.py: TC-01..TC-12 (unit + integration), all green. - Docs: CLAUDE.md, docs/architecture/README.md (component + schema + API), CHANGELOG. Invariant: the journal is an OBSERVER, not a Quality Gate — STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict / existing table schemas are byte-for-byte untouched; enduro not affected. never-raise on every public fn + injection. Refs: ORCH-098 Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 38 ++++ docs/architecture/README.md | 8 +- src/agents/launcher.py | 14 ++ src/config.py | 21 ++ src/db.py | 185 +++++++++++++++++ src/lessons.py | 191 +++++++++++++++++ src/main.py | 86 +++++++- src/stage_engine.py | 49 +++++ tests/test_lessons.py | 396 ++++++++++++++++++++++++++++++++++++ 9 files changed, 985 insertions(+), 3 deletions(-) create mode 100644 src/lessons.py create mode 100644 tests/test_lessons.py diff --git a/CLAUDE.md b/CLAUDE.md index e8426bc..c1ab1e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,6 +235,44 @@ kill-switch, never-raise, fail-safe → полный цикл. `docs/work-items/ORCH-027/06-adr/ADR-001-coverage-gate.md`, `docs/architecture/adr/adr-0029-coverage-gate.md`. +## Машинный журнал уроков (ORCH-098) +Шаг 1 («Фундамент», F2) эпика саморазвития: формализует свободнотекстовые «уроки» из `memory/` в +**машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих +ретроспективщика (E2), приоритизатора RICE (E3) и Стрим. Чистый **observer-leaf** `src/lessons.py` +(never-raise, kill-switch, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/ +`update()`/`snapshot()`. **Инвариант:** журнал — наблюдатель, **не** Quality Gate; запись урока +никогда не влияет на продвижение по стадиям — `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/ +machine-verdict/схемы существующих таблиц байт-в-байт не тронуты. +- **Таблица (D1):** аддитивная идемпотентная `lessons` (`CREATE TABLE IF NOT EXISTS` в `init_db()`, + три индекса) — контекст (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализ (`root_cause`/ + `suggestion`), статус (`status`/`related_task`), **атрибуция сразу и нуллабельно** (`attribution`/ + `target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже через update; + `_ensure_column` форвард-safe на старой таблице) + `source`/`detail`. Без `enum`-констрейнтов — + значения суть forward-compatible слаги. Хелперы `db.record_lesson`/`get_lessons`/`update_lesson`/ + `lessons_snapshot`/`lessons_recent_dup_exists`. +- **НЕ скоупится по репо (D2):** в отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate` имеют + `*_repos`, т.к. *действуют* на репо), журнал observer-only → единственный регулятор — глобальный + kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`); **`lessons_repos` НЕ + вводится**. Recorder пишет уроки про **любой** репо (включая enduro-trails — урок ценен для петли); + репо-разрез — на **выборке** (`get(repo=…)`). enduro не затронут (общая БД, аддитивная таблица). +- **Автозапись 4 типов (D3):** тонкие best-effort врезки (`source="auto"`, never-raise, дедуп) — + `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`, откат на `development`), `merge_hold` + (`stage_engine._handle_merge_verify` HOLD-ветка), `transient_retry` (`launcher._finalize_transient` + на **исчерпании** бюджета ретраев, а не на каждом backoff), `deploy_degraded` (post-deploy + `DEGRADED → set_repo_freeze`, урок слоя-3 «деплой OK / прод сломан» ET-8 — `attribution="unknown"`, + классифицируется позже). +- **Дедуп (D4):** для `source="auto"` — один indexed-SELECT по `idx_lessons_wi_type`: дубль с тем же + `(work_item_id, lesson_type, stage)` в окне `lessons_dedup_window_s` (env, дефолт 3600с) → no-op. + `source="manual"` дедуп НЕ проходит (оператор/Стрим всегда пишут). +- **Эндпоинты (D5):** `GET /lessons` (read-only, фильтры `type`/`status`/`repo`/`work_item`/`limit`), + `POST /lessons` (ручная запись, `source="manual"`), `POST /lessons/{id}` (доклассификация/update); + read-only ключ `lessons` в `GET /queue`. Выключенный флаг → `{"enabled": false}`. +- **never-raise (NFR-1):** все публичные функции и врезки изолированы (`try/except` → warning + + безопасный дефолт) — сбой журнала не роняет конвейер. Self-hosting-безопасно: только читает/пишет + свою таблицу, не деплоит/не рестартит прод/не трогает `main`/без процессов/сети. Детали — + `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`, + `docs/architecture/adr/adr-0033-lessons-journal.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a80007b..278f7bc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -20,7 +20,7 @@ - **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` неизменна (обратная совместимость). - **FS ownership detect** (`src/fs_normalize.py`, ORCH-057 — [adr-0031](adr/adr-0031-legacy-ownership-normalization.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`preflight`), закрывает пробел ORCH-040: при миграции на `user: "1000:1000"` legacy `root:root` файлы в `/repos` ломали создание worktree под uid 1000 (`ensure_worktree` → сырой `fatal: … Permission denied`, агент не стартовал). Три слоя: (1) **D1** — `src/git_worktree.py::ensure_worktree` классифицирует класс «нет прав» (`Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/`EPERM`) и поднимает actionable `RuntimeError` с причиной + лечащей командой (не-прав-ошибки сохраняют прежний контракт — меняется только формулировка, не факт сбоя); (2) **D2** — `scan_ownership(roots, target_uid=os.getuid())` обходит `/repos/_wt`, `/.git/{objects,worktrees}`, `data/runs` с ранним выходом при первом `st_uid != target_uid` + TTL-кэш; (3) **D3** — best-effort вызов на старте `main.lifespan` → WARNING + Telegram при mismatch (claim **НЕ** блокируется — внятный ранний отказ даёт D1 в точке launch, знающей repo; preflight-блок отвергнут как repo-слепой → регресс enduro). Опц. `normalize()` chown'ит только при `CAP_CHOWN` (под uid 1000 — no-op; init-контейнер/root-entrypoint отвергнуты — реинтродукция root-контекста + self-deploy compose). Фактическая нормализация = **операторская процедура** под root на хосте (`INFRA.md` «Миграция uid»). Условность `applies(repo)` first: `fs_normalize_enabled` (kill-switch) + `fs_normalize_repos` (CSV, пусто → self-hosting only). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST /fs-normalize/check`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`. - **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`/схема БД — не тронуты. -- **Lessons journal** (`src/lessons.py` + таблица `lessons`, ORCH-098 — design, [adr-0034](adr/adr-0034-lessons-journal.md)) — машинный журнал уроков (структурированная база отклонений конвейера); шаг 1 эпика саморазвития (домен 0 «Фундамент», F2; топливо петли самообучения 8A), фундамент для будущих ретроспективщика (E2)/приоритизатора RICE (E3)/Стрим. Чистый **observer-leaf** (never-raise, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/`update()`/`snapshot()`. **Аддитивная идемпотентная таблица `lessons`** (`CREATE TABLE IF NOT EXISTS` в `init_db()`, restart-safe) с полями контекста (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализа (`root_cause`/`suggestion`), статуса (`status`/`related_task`) и **атрибуции — сразу и нуллабельно** (`attribution`/`target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже ретроспективщиком/человеком) + `source`/`detail`; без `enum`-констрейнтов (слаги forward-compatible). **Автозапись 4 типов** (`source="auto"`, best-effort, дедуп в окне; `transient_retry` — только на исчерпании бюджета ретраев) тонкими врезками: `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`), `merge_hold` (`merge_gate._handle_merge_verify` HOLD), `transient_retry` (merge-retry/launcher transient budget-exhaustion), `deploy_degraded` (post-deploy `DEGRADED → set_repo_freeze`, урок слоя-3 «деплой OK / прод сломан», ET-8). Эндпоинты `GET /lessons` (read-only, фильтры), `POST /lessons` (ручная запись), `POST /lessons/{id}` (update/доклассификация), + read-only ключ `lessons` в `GET /queue`. **Расхождение с гейт-шаблоном:** журнал observer-only → **НЕ скоупится по репо** (kill-switch `lessons_enabled` only, без `lessons_repos`); репо-разрез — на выборке (`repo`-колонка/фильтр), enduro не затронут (общая БД, аддитивная таблица). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц — байт-в-байт не тронуты (журнал не участвует в решении гейта). Kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`). Детали — `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`. +- **Lessons journal** (`src/lessons.py` + таблица `lessons`, ORCH-098 — реализовано, [adr-0034](adr/adr-0034-lessons-journal.md)) — машинный журнал уроков (структурированная база отклонений конвейера); шаг 1 эпика саморазвития (домен 0 «Фундамент», F2; топливо петли самообучения 8A), фундамент для будущих ретроспективщика (E2)/приоритизатора RICE (E3)/Стрим. Чистый **observer-leaf** (never-raise, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/`update()`/`snapshot()`. **Аддитивная идемпотентная таблица `lessons`** (`CREATE TABLE IF NOT EXISTS` в `init_db()`, restart-safe) с полями контекста (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализа (`root_cause`/`suggestion`), статуса (`status`/`related_task`) и **атрибуции — сразу и нуллабельно** (`attribution`/`target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже ретроспективщиком/человеком) + `source`/`detail`; без `enum`-констрейнтов (слаги forward-compatible). **Автозапись 4 типов** (`source="auto"`, best-effort, дедуп в окне; `transient_retry` — только на исчерпании бюджета ретраев) тонкими врезками: `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`), `merge_hold` (`merge_gate._handle_merge_verify` HOLD), `transient_retry` (merge-retry/launcher transient budget-exhaustion), `deploy_degraded` (post-deploy `DEGRADED → set_repo_freeze`, урок слоя-3 «деплой OK / прод сломан», ET-8). Эндпоинты `GET /lessons` (read-only, фильтры), `POST /lessons` (ручная запись), `POST /lessons/{id}` (update/доклассификация), + read-only ключ `lessons` в `GET /queue`. **Расхождение с гейт-шаблоном:** журнал observer-only → **НЕ скоупится по репо** (kill-switch `lessons_enabled` only, без `lessons_repos`); репо-разрез — на выборке (`repo`-колонка/фильтр), enduro не затронут (общая БД, аддитивная таблица). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц — байт-в-байт не тронуты (журнал не участвует в решении гейта). Kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`). Детали — `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`. - **Sidecar-watchdog F1b** (`watchdog/` + сервис `orchestrator-watchdog`, ORCH-100 — [adr-0033](adr/adr-0033-sidecar-watchdog.md)) — **мозг мониторинга в ОТДЕЛЬНОМ контейнере** (наблюдатель отделён от наблюдаемого, C-1): код в репо орка (`watchdog/`), рантайм — свой образ (`watchdog/Dockerfile`, `python:3.12-slim`, **stdlib-only**) + сервис в `docker-compose.yml` (`network_mode: host`, read-only `docker.sock`, `mem_limit: 128m`). На каждом тике собирает 4 источника: `GET /metrics` орка (F1a/ORCH-099), хост (диск/inode/память/CPU, stdlib), статусы контейнеров через read-only `docker.sock` (GET-only, без `docker` SDK), пинг Plane/Gitea/Anthropic. Каждый сигнал → **обобщённая чистая** `decide(signal_active, prev, now, cooldown)` (генерализация `disk_watchdog.decide_action`, per-signal in-memory `AlertState`) → алерт в **собственный** Telegram-канал sidecar (`WATCHDOG_TG_*`, **НЕ** импорт `src/notifications.py`). Особый сигнал `orch_down` — `/metrics` не отвечает (наблюдатель жив, наблюдаемый лёг). Диск: штатные 85% остаются за `disk_watchdog` (ORCH-063, нулевой дубль), sidecar — `orch_down` + opt-in потолок 97% (default off). never-raise, kill-switch `WATCHDOG_ENABLED`, строго read-only к наблюдаемому; `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД орка — не тронуты. Подробнее ниже (§ «Sidecar-watchdog F1b»). Детали — `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`. ## Сырьё-эндпоинт `/metrics` для sidecar (ORCH-099 — design) @@ -1086,6 +1086,7 @@ Monitoring after Deploy → Done - `jobs` — очередь задач (ORCH-1); статусы `queued|running|done|failed|cancelled` (ORCH-090: `cancelled` — терминальный исход STOP, нигде не реквью'ится); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом - `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A» - `repo_freeze` — durable per-repo rollback-freeze (ORCH-088, FR-5): `(id, repo, frozen_at, reason, work_item_id, cleared_at)`, аддитивная append-only; активный freeze ⇔ строка репо с `cleared_at IS NULL`. Выставляется post-deploy `DEGRADED` (`set_repo_freeze`), снимается вручную (`POST /serial-gate/unfreeze` → `cleared_at=now`). Гейтит serial-claim безусловно (деградировавшая задача уже `done`) +- `lessons` — машинный журнал отклонений конвейера (ORCH-098, FR-1): `(id, created_at, updated_at, lesson_type, work_item_id, task_id, stage, agent, repo, root_cause, suggestion, status, related_task, attribution, target_repo, target_domain, source, detail)`, аддитивная идемпотентная (`CREATE TABLE IF NOT EXISTS` + три индекса); колонки атрибуции (`attribution`/`target_repo`/`target_domain`) — нуллабельны и присутствуют сразу (NFR-6), без `enum`-констрейнтов (слаги forward-compatible). Автозапись 4 типов (`gate_failure`/`merge_hold`/`transient_retry`/`deploy_degraded`, `source="auto"`, дедуп в окне `lessons_dedup_window_s`) + ручная (`source="manual"`); observer-only (не участвует в решении гейта). Leaf `src/lessons.py` never-raise, kill-switch `lessons_enabled` (без `*_repos` — журнал не скоупится по репо, репо-разрез на выборке) ## Изоляция (git worktree, ORCH-2) Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/`. @@ -1095,9 +1096,12 @@ Monitoring after Deploy → Done |--------|------|----------| | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != done) | -| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + последние jobs | +| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + последние jobs | | GET | `/metrics` | ORCH-099 (FND/F1a): read-only машинное «сырьё» для sidecar F1b — конверт `schema_version`/`generated_at`/`clk_tck` + разделы `stages`/`queue`/`agents` (liveness: pid/runtime/cpu_ticks)/`cost`. never-raise по разделам; kill-switch `ORCH_METRICS_ENABLED` (дефолт `True`). Контракт — см. раздел «Сырьё-эндпоинт `/metrics`» | | POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` | +| GET | `/lessons` | ORCH-098 (FR-4): read-only выборка журнала уроков; query-фильтры `type`/`status`/`repo`/`work_item`/`limit` → `{enabled, lessons:[…]}` (всегда `200`, чтение не мутирует). При `lessons_enabled=False` → `{enabled:false, lessons:[]}` | +| POST | `/lessons` | ORCH-098 (FR-5): ручная запись урока (JSON-тело, `lesson_type` обязателен, `source="manual"` не дедупится) → `{id}`; при выключенном флаге → `{enabled:false}` | +| POST | `/lessons/{id}` | ORCH-098 (FR-5): доклассификация/обновление урока (`status`/`attribution`/`target_*`/`related_task`/`root_cause`/`suggestion`), стампит `updated_at` → `{ok}` | | POST | `/webhook/plane` | Plane webhook | | POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) | diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 15eb41d..ba1b744 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -1016,6 +1016,20 @@ class AgentLauncher: ) self._notify_failed(job_id, agent, job, run_id, f"transient (rate-limit) after {tattempts} attempts") + # ORCH-098 (FR-3c / D3): auto-record a `transient_retry` lesson ONLY on + # budget EXHAUSTION (not on each backoff — that would be noise; the + # valuable signal is "transients exhausted"). best-effort, never-raise, + # deduped; can't escape into the queue-worker path. + try: + from ..lessons import record as record_lesson, LessonType + record_lesson( + LessonType.TRANSIENT_RETRY, + task_id=job.get("task_id"), repo=job.get("repo"), agent=agent, + root_cause=f"transient retry budget exhausted ({tattempts}/{tmax})", + detail=err, source="auto", + ) + except Exception as e: # noqa: BLE001 - never break the queue worker + logger.warning(f"Job {job_id}: lessons transient_retry record failed: {e}") def _finalize_permanent(self, job_id, agent, run_id, exit_code, job): """Permanent (code-fault) failure -> normal attempts SINGLE kill-switch (env ORCH_LESSONS_ENABLED). + # False -> record/get/update/snapshot inert (no DB + # access), endpoints return {"enabled": false}, + # auto-record injections no-op. Default True. + # lessons_query_limit_default-> default LIMIT for GET /lessons / get() when the + # caller passes none. + # lessons_dedup_window_s -> auto-record dedup window (s): a second auto lesson + # with the same (work_item_id, lesson_type, stage) + # inside this window is suppressed (D4). manual + # records are never deduped. Default 3600 (1h). + lessons_enabled: bool = True + lessons_query_limit_default: int = 100 + lessons_dedup_window_s: int = 3600 + # ORCH-057: legacy root-owned file ownership detect + actionable worktree error # (follow-up ORCH-040). Three additive, kill-switch-reversible layers: (1) an # actionable RuntimeError in git_worktree.ensure_worktree when a worktree fails diff --git a/src/db.py b/src/db.py index 6aca2e0..a158985 100644 --- a/src/db.py +++ b/src/db.py @@ -220,10 +220,195 @@ def init_db(): updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); """) + # ORCH-098 (FR-1, ADR-001 D1): additive machine lessons-journal — a structured + # table of pipeline deviations (gate-fail / merge-hold / transient-retry / + # post-deploy-degraded), the foundation of the self-improvement epic (E2 + # retrospective / E3 RICE prioritiser). Purely ADDITIVE (CREATE TABLE/INDEX IF NOT + # EXISTS, pattern repo_freeze/coverage_baseline) -> idempotent, restart-safe on + # the shared prod DB; existing tables untouched (NFR-3, enduro-trails not + # affected). The attribution columns (attribution/target_repo/target_domain) are + # NULLABLE and present FROM THE START (Слава 10.06, NFR-6) so the live shared DB + # never needs a schema rework — an auto-recorded `unknown` lesson is classified + # later via update. lesson_type / attribution / target_domain carry NO enum/CHECK + # constraint: the values are a forward-compatible slug convention (a new lesson + # type never needs a migration). See docs/work-items/ORCH-098/08-data-requirements.md. + conn.executescript(""" + 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, + work_item_id TEXT, + task_id INTEGER, + stage TEXT, + agent TEXT, + repo TEXT, + root_cause TEXT, + suggestion TEXT, + status TEXT NOT NULL DEFAULT 'new', + related_task TEXT, + attribution TEXT, + target_repo TEXT, + target_domain TEXT, + source TEXT, + detail TEXT + ); + 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); + """) + # Forward-safe: on an already-created `lessons` table the attribution columns are + # added idempotently (_ensure_column is a no-op once present) so an old prod DB + # picks them up without a data migration (NFR-6, AC-2). + _ensure_column(conn, "lessons", "attribution", "TEXT") + _ensure_column(conn, "lessons", "target_repo", "TEXT") + _ensure_column(conn, "lessons", "target_domain", "TEXT") conn.commit() conn.close() +# --------------------------------------------------------------------------- +# ORCH-098 (FR-1..FR-5, ADR-001 D1): lessons-journal DDL helpers. Each opens its +# own connection and closes it in `finally` (pattern coverage_baseline). The leaf +# src/lessons.py wraps these in its never-raise contract — these may raise on a +# real DB fault (the leaf swallows it). +# --------------------------------------------------------------------------- +# The full column set, in INSERT order. Single source of truth so record/get stay +# in lockstep with the schema. +_LESSON_COLUMNS = ( + "lesson_type", "work_item_id", "task_id", "stage", "agent", "repo", + "root_cause", "suggestion", "status", "related_task", + "attribution", "target_repo", "target_domain", "source", "detail", +) +# Fields an update() may set (everything mutable; never id/created_at/lesson_type). +_LESSON_UPDATABLE = ( + "status", "attribution", "target_repo", "target_domain", "related_task", + "root_cause", "suggestion", "stage", "agent", "repo", "detail", +) + + +def record_lesson(**fields) -> int: + """Insert one lessons row; return the new id. Raises only on a real DB fault. + + Only the known columns in ``_LESSON_COLUMNS`` are written; unknown keys are + ignored (forward-safe). ``created_at`` is stamped by the table default. + """ + cols = [c for c in _LESSON_COLUMNS if c in fields] + if "lesson_type" not in cols: + raise ValueError("record_lesson requires lesson_type") + placeholders = ", ".join("?" for _ in cols) + sql = f"INSERT INTO lessons ({', '.join(cols)}) VALUES ({placeholders})" + conn = get_db() + try: + cur = conn.execute(sql, tuple(fields[c] for c in cols)) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def lessons_recent_dup_exists(work_item_id, lesson_type, stage, window_s: int) -> bool: + """ORCH-098 (D4): is there an auto-lesson with the same (work_item_id, + lesson_type, stage) within the last ``window_s`` seconds? One indexed lookup on + ``idx_lessons_wi_type``. Used to suppress duplicate auto-records on retries. + """ + conn = get_db() + try: + row = conn.execute( + "SELECT 1 FROM lessons " + "WHERE work_item_id IS ? AND lesson_type = ? AND stage IS ? " + "AND source = 'auto' " + "AND created_at > datetime('now', ?) LIMIT 1", + (work_item_id, lesson_type, stage, f"-{int(window_s)} seconds"), + ).fetchone() + finally: + conn.close() + return row is not None + + +def get_lessons(*, lesson_type=None, status=None, repo=None, work_item_id=None, + limit: int = 100) -> list[dict]: + """Read-only parametrised SELECT of lessons (ORDER BY id DESC LIMIT ?).""" + where = [] + params: list = [] + if lesson_type: + where.append("lesson_type = ?") + params.append(lesson_type) + if status: + where.append("status = ?") + params.append(status) + if repo: + where.append("repo = ?") + params.append(repo) + if work_item_id: + where.append("work_item_id = ?") + params.append(work_item_id) + sql = "SELECT * FROM lessons" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY id DESC LIMIT ?" + try: + lim = int(limit) + except (TypeError, ValueError): + lim = 100 + params.append(max(1, lim)) + conn = get_db() + try: + rows = conn.execute(sql, tuple(params)).fetchall() + finally: + conn.close() + return [dict(r) for r in rows] + + +def update_lesson(lesson_id: int, **fields) -> bool: + """Update mutable fields of a lesson + stamp updated_at. Returns True iff a row + changed. Unknown / non-updatable keys are ignored (forward-safe). + """ + sets = [c for c in _LESSON_UPDATABLE if c in fields] + if not sets: + return False + assignments = ", ".join(f"{c} = ?" for c in sets) + sql = f"UPDATE lessons SET {assignments}, updated_at = datetime('now') WHERE id = ?" + conn = get_db() + try: + cur = conn.execute(sql, tuple(fields[c] for c in sets) + (int(lesson_id),)) + conn.commit() + return (cur.rowcount or 0) > 0 + finally: + conn.close() + + +def lessons_snapshot(recent: int = 10) -> dict: + """Light GROUP BY summary (counts by type/status) + the last N lessons, for the + GET /queue observability block.""" + conn = get_db() + try: + total = conn.execute("SELECT COUNT(*) FROM lessons").fetchone()[0] + by_type = { + r["lesson_type"]: r["n"] + for r in conn.execute( + "SELECT lesson_type, COUNT(*) AS n FROM lessons GROUP BY lesson_type" + ).fetchall() + } + by_status = { + r["status"]: r["n"] + for r in conn.execute( + "SELECT status, COUNT(*) AS n FROM lessons GROUP BY status" + ).fetchall() + } + rows = conn.execute( + "SELECT * FROM lessons ORDER BY id DESC LIMIT ?", (max(1, int(recent)),) + ).fetchall() + finally: + conn.close() + return { + "total": total, + "by_type": by_type, + "by_status": by_status, + "recent": [dict(r) for r in rows], + } + + def get_coverage_baseline(repo: str) -> float | None: """ORCH-027: read the per-repo coverage baseline (%, line coverage). diff --git a/src/lessons.py b/src/lessons.py new file mode 100644 index 0000000..2e3c054 --- /dev/null +++ b/src/lessons.py @@ -0,0 +1,191 @@ +"""ORCH-098 (FND/F2): machine lessons-journal — a never-raise observer leaf. + +Background +---------- +The orchestrator runs an autonomous pipeline; when it deviates (a quality gate +rolls a task back, a merge is held, a transient burst exhausts the retry budget, +a post-deploy verdict comes back DEGRADED) the only trace today is free-text in +``memory/`` — not machine-readable, so nothing can count the patterns or +prioritise the fixes. ORCH-098 is step 1 («Фундамент», F2) of the +self-improvement epic: it formalises those deviations into a structured +``lessons`` table on which the future retrospective agent (E2), the RICE +prioritiser (E3) and Стрим will stand. + +Design (ADR-001, by образцу ``serial_gate`` / ``coverage_gate`` / ``metrics``) +------------------------------------------------------------------------------ +This is a **leaf**: it imports only ``config`` + ``db`` (lazily). It NEVER imports +``stage_engine`` / ``merge_gate`` / ``launcher`` (anti-cycle) — those choke-points +call INTO this module, never the reverse. + +Two contract invariants, both load-bearing on the shared self-hosting prod DB: + + * **kill-switch** (FR-6 / AC-7): ``lessons_enabled=False`` -> every public + function is an immediate no-op (``record→None``, ``get→[]``, ``update→False``, + ``snapshot→{}``) WITHOUT touching the DB; the auto-record injections become + no-ops; pipeline behaviour is byte-for-byte the pre-ORCH-098 behaviour. + * **never-raise** (NFR-1 / AC-6): with the switch on, every body runs under + ``try/except Exception -> logger.warning + safe default``. A journal fault + (a failing DB, a bad row) can NEVER propagate into the hot path that called it + (a rollback / HOLD / retry must complete regardless). + +**No repo scope (D2).** Unlike the gate leaves (``serial_gate`` / ``coverage_gate`` +/ ``bug_fast_track`` carry a ``*_repos`` CSV because they *act* on a repo), the +journal is observer-only: writing a row never influences any repo's pipeline. +So it records lessons about ANY repo — including enduro-trails (a degraded enduro +deploy is a valuable self-learning signal; a repo scope would drop it). The +repo cut lives on the READ side (``get(repo=...)`` / ``snapshot``). enduro is not +affected (NFR-3): an observer row about enduro changes no enduro stage/gate. + +Self-hosting safety (NFR-7): the journal only reads/writes its own table. It never +deploys, never restarts prod, never touches ``main``, spawns no process, opens no +socket. +""" +from __future__ import annotations + +import logging + +from .config import settings + +logger = logging.getLogger("orchestrator.lessons") + + +# --------------------------------------------------------------------------- +# Slug conventions (NOT enum constraints — forward-compatible string slugs, D1). +# Exposed as constants so the choke-point injections and tests share one spelling. +# --------------------------------------------------------------------------- +class LessonType: + """Canonical ``lesson_type`` slugs written by the auto-detectors (D3).""" + GATE_FAILURE = "gate_failure" # QG rollback to development + MERGE_HOLD = "merge_hold" # merge not verified -> task held on deploy + TRANSIENT_RETRY = "transient_retry" # transient retry budget exhausted + DEPLOY_DEGRADED = "deploy_degraded" # post-deploy DEGRADED -> repo freeze + + +class Attribution: + """``attribution`` slugs (who a lesson is about — filled in later by a human / + the retrospective agent; auto-records leave it NULL or ``unknown``).""" + PLATFORM = "platform" + PROJECT = "project" + BOTH = "both" + UNKNOWN = "unknown" + + +class Domain: + """``target_domain`` slugs (which improvement axis a lesson touches).""" + RELIABILITY = "reliability" + QUALITY = "quality" + ECONOMY = "economy" + FEATURES = "features" + SCALE = "scale" + + +class Status: + """``status`` lifecycle slugs.""" + NEW = "new" + IN_PROGRESS = "in_progress" + CLOSED = "closed" + LINKED = "linked" + + +def _enabled() -> bool: + """Read the kill-switch; never raises (a config read fault -> treated as off).""" + try: + return bool(settings.lessons_enabled) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("lessons: kill-switch read error: %s", e) + return False + + +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: + """Record one lesson; return its new id, or ``None`` (no-op / error / deduped). + + * Kill-switch off -> immediate ``None`` WITHOUT a DB access (FR-6 / AC-7). + * ``source="auto"`` records are DEDUPED (D4): a prior auto-lesson with the same + ``(work_item_id, lesson_type, stage)`` within ``lessons_dedup_window_s`` -> + ``None`` (so transient retry-storms / repeated rollbacks don't flood the + table). ``source="manual"`` is NEVER deduped (the operator / Стрим can always + write). + * never-raise (NFR-1 / AC-6): any DB / internal error -> ``logger.warning`` + + ``None``; the caller (a hot-path rollback / HOLD / retry) is untouched. + """ + if not _enabled(): + return None + if not lesson_type: + return None + try: + from . import db + if source == "auto": + try: + window = int(getattr(settings, "lessons_dedup_window_s", 3600) or 0) + except (TypeError, ValueError): + window = 3600 + if window > 0 and db.lessons_recent_dup_exists( + work_item_id, lesson_type, stage, window + ): + logger.debug( + "lessons: deduped auto %s for %s/%s (within %ss window)", + lesson_type, work_item_id, stage, window, + ) + return None + return db.record_lesson( + lesson_type=lesson_type, work_item_id=work_item_id, task_id=task_id, + stage=stage, agent=agent, repo=repo, root_cause=root_cause, + suggestion=suggestion, status=status, related_task=related_task, + attribution=attribution, target_repo=target_repo, + target_domain=target_domain, source=source, detail=detail, + ) + except Exception as e: # noqa: BLE001 - never-raise contract (NFR-1 / AC-6) + logger.warning("lessons.record(%s) error: %s", lesson_type, e) + return None + + +def get(*, lesson_type=None, status=None, repo=None, work_item_id=None, + limit=None) -> list[dict]: + """Read-only fetch of lessons (newest first). never-raise -> ``[]`` on error / + when the kill-switch is off.""" + if not _enabled(): + return [] + try: + if limit is None: + limit = getattr(settings, "lessons_query_limit_default", 100) + from . import db + return db.get_lessons( + lesson_type=lesson_type, status=status, repo=repo, + work_item_id=work_item_id, limit=limit, + ) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("lessons.get error: %s", e) + return [] + + +def update(lesson_id, **fields) -> bool: + """Re-classify / re-status an existing lesson (status / attribution / target_* / + related_task / root_cause / suggestion). Stamps ``updated_at``. never-raise -> + ``False`` on error / kill-switch off.""" + if not _enabled(): + return False + try: + from . import db + return db.update_lesson(lesson_id, **fields) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("lessons.update(%s) error: %s", lesson_id, e) + return False + + +def snapshot() -> dict: + """Light read-only summary for the GET /queue ``lessons`` block. never-raise -> + a minimal dict (``{"enabled": False}`` when off / ``{"enabled": True}`` on + error).""" + if not _enabled(): + return {"enabled": False} + try: + from . import db + out = {"enabled": True} + out.update(db.lessons_snapshot()) + return out + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("lessons.snapshot error: %s", e) + return {"enabled": True} diff --git a/src/main.py b/src/main.py index 2ca7d28..5f0f107 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request from contextlib import asynccontextmanager import logging from .db import init_db @@ -213,6 +213,7 @@ async def queue(): from . import labels from . import cancel from . import bug_fast_track + from . import lessons from .disk_watchdog import disk_watchdog from .build_cache_pruner import build_cache_pruner return { @@ -248,6 +249,10 @@ async def queue(): # kill-switch, label, scope, bug-task counts + the structural savings metric # (architecture stages skipped). Additive block; never-raise. "bug_fast_track": bug_fast_track.snapshot(), + # ORCH-098 (FR-4 / AC-4): lessons-journal observability (read-only) — + # kill-switch + counts by type/status + last N lessons. Additive block; + # never-raise (snapshot() returns {"enabled": ...} minimum on error). + "lessons": lessons.snapshot(), # ORCH-063 (FR-6 / AC-7): disk-watchdog observability (read-only) — # enabled, threshold, interval, last measurement per host-path. Additive # block; never-raise (status() returns {"enabled": ...} minimum on error). @@ -390,3 +395,82 @@ async def bug_fast_track_escalate(work_item: str = ""): except Exception: pass return {"ok": True, "work_item": work_item, "track": "full", "was": prev_track} + + +# --------------------------------------------------------------------------- +# ORCH-098 (FR-4 / FR-5, ADR-001 D5): machine lessons-journal endpoints. +# Read-only fetch + manual record + re-classify. All never-raise; with the +# kill-switch off they return {"enabled": false} (style of /metrics, AC-7). +# --------------------------------------------------------------------------- +@app.get("/lessons") +async def lessons_list( + type: str = "", status: str = "", repo: str = "", work_item: str = "", + limit: int | None = None, +): + """ORCH-098: read-only lessons fetch with optional filters (type / status / repo + / work_item / limit). Always 200; reading never mutates. ``lessons_enabled=False`` + -> ``{"enabled": false}``.""" + from . import lessons + from .config import settings + if not getattr(settings, "lessons_enabled", True): + return {"enabled": False, "lessons": []} + rows = lessons.get( + lesson_type=(type or None), status=(status or None), repo=(repo or None), + work_item_id=(work_item or None), limit=limit, + ) + return {"enabled": True, "lessons": rows} + + +@app.post("/lessons") +async def lessons_create(request: Request): + """ORCH-098: manually record a lesson (``source="manual"``, never deduped). JSON + body: ``lesson_type`` (required) + optional context / analysis / attribution + fields. Returns ``{"id": }`` or ``{"enabled": false}`` / + ``{"error": ...}``.""" + from . import lessons + from .config import settings + if not getattr(settings, "lessons_enabled", True): + return {"enabled": False} + try: + body = await request.json() + except Exception: # noqa: BLE001 - malformed body + body = {} + if not isinstance(body, dict): + body = {} + lesson_type = body.get("lesson_type") + if not lesson_type: + return {"ok": False, "error": "missing 'lesson_type'"} + # Only forward known fields; source is forced to "manual" (operator/Стрим). + allowed = ( + "work_item_id", "task_id", "stage", "agent", "repo", "root_cause", + "suggestion", "status", "related_task", "attribution", "target_repo", + "target_domain", "detail", + ) + kwargs = {k: body[k] for k in allowed if k in body} + new_id = lessons.record(lesson_type, source="manual", **kwargs) + return {"id": new_id} + + +@app.post("/lessons/{lesson_id}") +async def lessons_update(lesson_id: int, request: Request): + """ORCH-098: re-classify / re-status an existing lesson (status / attribution / + target_* / related_task / root_cause / suggestion). Lets a human / the + retrospective agent classify an auto-recorded ``unknown``. Returns + ``{"ok": bool}`` or ``{"enabled": false}``.""" + from . import lessons + from .config import settings + if not getattr(settings, "lessons_enabled", True): + return {"enabled": False} + try: + body = await request.json() + except Exception: # noqa: BLE001 - malformed body + body = {} + if not isinstance(body, dict): + body = {} + allowed = ( + "status", "attribution", "target_repo", "target_domain", "related_task", + "root_cause", "suggestion", "stage", "agent", "repo", "detail", + ) + kwargs = {k: body[k] for k in allowed if k in body} + ok = lessons.update(lesson_id, **kwargs) + return {"ok": ok} diff --git a/src/stage_engine.py b/src/stage_engine.py index 3d4bbbb..a1e65de 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -927,6 +927,24 @@ def _handle_qg_failure_rollbacks( f"development ({reason})" ) + # ORCH-098 (FR-3a / D3): machine lessons-journal — auto-record a `gate_failure` + # lesson whenever a quality gate rolled this task back to `development` + # (reviewer REQUEST_CHANGES / tester FAIL / staging FAILED / deploy FAILED — all + # four branches above set result.rolled_back_to="development"). One best-effort + # call covers every rollback branch; lessons.record is never-raise + deduped, and + # this guard ensures even an import fault can't escape into the hot rollback path. + if result.rolled_back_to == "development": + try: + from . import lessons + lessons.record( + lessons.LessonType.GATE_FAILURE, + work_item_id=work_item_id, task_id=task_id, stage=current_stage, + agent=agent, repo=repo, root_cause=reason, detail=qg_name, + source="auto", + ) + except Exception as e: # noqa: BLE001 - never break the rollback path + logger.warning(f"Task {task_id}: lessons gate_failure record failed: {e}") + # --------------------------------------------------------------------------- # ORCH-043: merge-gate sub-gate on the deploy-staging -> deploy edge @@ -1726,6 +1744,19 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes result.alerted = True result.note = "merge-not-verified-hold" result.advanced = False + # ORCH-098 (FR-3b / D3): auto-record a `merge_hold` lesson — deploy succeeded + # but `main` never got the commit, so the task is held on `deploy` (not done). + # best-effort, never-raise, deduped; can't escape into the HOLD path. + try: + from . import lessons + lessons.record( + lessons.LessonType.MERGE_HOLD, + work_item_id=work_item_id, task_id=task_id, stage="deploy", + repo=repo, root_cause="merge-not-verified-hold", detail=merge_msg, + source="auto", + ) + except Exception as e: # noqa: BLE001 - never break the HOLD + logger.warning(f"Task {task_id}: lessons merge_hold record failed: {e}") return True except Exception as e: # noqa: BLE001 - never-raise contract (INV-1/AC-7) # Any internal error -> treat as "not confirmed" -> HOLD + alert, never crash. @@ -2009,6 +2040,24 @@ def run_post_deploy_monitor(job: dict): except Exception as e: # noqa: BLE001 - never break the tick logger.warning(f"post-deploy: set_repo_freeze failed for {repo}: {e}") + # ORCH-098 (FR-3d / D3): auto-record a `deploy_degraded` lesson — "deploy OK / + # prod broken" (layer-3, ET-8). attribution left "unknown" + target_domain + # "reliability" for a human / the retrospective agent to classify later (this is + # exactly the signal Слава required the attribution columns for). best-effort, + # never-raise; can't escape into the monitor tick. + try: + from . import lessons + reason = f"post-deploy DEGRADED ({checks_failed}/{checks_total})" + lessons.record( + lessons.LessonType.DEPLOY_DEGRADED, + work_item_id=work_item_id, repo=repo, stage="deploy", + root_cause=reason, attribution=lessons.Attribution.UNKNOWN, + target_repo=repo, target_domain=lessons.Domain.RELIABILITY, + source="auto", + ) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: lessons deploy_degraded record failed for {repo}: {e}") + post_deploy.write_post_deploy_log( repo, work_item_id, branch, post_deploy.DEGRADED, action_taken, settings.post_deploy_window_s, checks_total, checks_failed, diff --git a/tests/test_lessons.py b/tests/test_lessons.py new file mode 100644 index 0000000..83759b2 --- /dev/null +++ b/tests/test_lessons.py @@ -0,0 +1,396 @@ +"""ORCH-098 / TC-01..TC-12: the machine lessons-journal (src/lessons.py + db + wiring). + +Contract under test (ADR-001 §7 / acceptance-criteria): + * the `lessons` table is additive + idempotent and carries the NULLABLE + attribution columns (attribution / target_repo / target_domain) from the start; + * record() inserts a row (auto/manual) and returns its id; auto records are + deduped in a window, manual records are never deduped; + * never-raise: a failing DB -> None/[]/{}/False, never an exception into the caller; + * kill-switch off -> record/get/update/snapshot inert (no DB access); + * get_lessons filters by type/status/repo/work_item + LIMIT + ORDER BY id DESC; + * update_lesson mutates fields + stamps updated_at; unknown id is safe; + * auto-record wiring: a QG rollback to development writes a `gate_failure` lesson; + a launcher transient-budget-exhaustion writes a `transient_retry` lesson; a + failing journal never breaks the hot path; + * the HTTP endpoints (GET /lessons, POST /lessons, POST /lessons/{id}) and the + GET /queue `lessons` block behave + honour the kill-switch; + * pipeline invariants (STAGE_TRANSITIONS / QG_CHECKS) are structurally untouched. +""" +import os +import tempfile + +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_lessons.db") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import pytest # noqa: E402 + +import src.db as db # noqa: E402 +from src import config as cfg # noqa: E402 +from src import lessons # noqa: E402 + +_REPO = "orchestrator" +_WI = "ORCH-098" + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + """Isolated tmp SQLite DB + journal ON by default.""" + dbfile = tmp_path / "lessons.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "lessons_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "lessons_query_limit_default", 100, raising=False) + monkeypatch.setattr(cfg.settings, "lessons_dedup_window_s", 3600, raising=False) + db.init_db() + yield + + +def _columns(): + conn = db.get_db() + try: + return {r[1] for r in conn.execute("PRAGMA table_info(lessons)").fetchall()} + finally: + conn.close() + + +# =========================================================================== +# TC-01 — additive + idempotent table with all BR-1 fields +# =========================================================================== +def test_tc01_table_idempotent_and_fields(): + # Double init must not raise nor duplicate. + db.init_db() + db.init_db() + cols = _columns() + for f in ( + "id", "created_at", "updated_at", "lesson_type", "work_item_id", "task_id", + "stage", "agent", "repo", "root_cause", "suggestion", "status", "related_task", + ): + assert f in cols, f"missing column {f}" + # No existing table mutated: tasks/jobs still present and unchanged in shape. + conn = db.get_db() + try: + tabs = { + r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + finally: + conn.close() + assert {"tasks", "jobs", "agent_runs", "lessons"} <= tabs + + +# =========================================================================== +# TC-02 — attribution columns present from the start, nullable, set later +# =========================================================================== +def test_tc02_attribution_columns_nullable_and_settable(): + cols = _columns() + assert {"attribution", "target_repo", "target_domain"} <= cols + # A record WITHOUT attribution is accepted (NULL). + lid = lessons.record(lessons.LessonType.DEPLOY_DEGRADED, work_item_id=_WI, repo=_REPO) + assert lid is not None + rows = lessons.get(work_item_id=_WI) + assert rows[0]["attribution"] is None + # Attribution can be filled in later via update. + assert lessons.update( + lid, attribution=lessons.Attribution.PLATFORM, + target_repo=_REPO, target_domain=lessons.Domain.RELIABILITY, + ) is True + rows = lessons.get(work_item_id=_WI) + assert rows[0]["attribution"] == "platform" + assert rows[0]["target_domain"] == "reliability" + + +# =========================================================================== +# TC-03 — record() inserts and returns id, created_at filled, source honoured +# =========================================================================== +def test_tc03_record_inserts_and_returns_id(): + lid = lessons.record( + lessons.LessonType.GATE_FAILURE, work_item_id=_WI, task_id=7, stage="review", + agent="reviewer", repo=_REPO, root_cause="REQUEST_CHANGES", source="auto", + ) + assert isinstance(lid, int) and lid > 0 + rows = lessons.get(work_item_id=_WI) + assert len(rows) == 1 + r = rows[0] + assert r["lesson_type"] == "gate_failure" + assert r["task_id"] == 7 + assert r["agent"] == "reviewer" + assert r["source"] == "auto" + assert r["status"] == "new" + assert r["created_at"] + # A manual record with a different (work_item, type) -> distinct row. + lid2 = lessons.record("custom_manual", work_item_id="ORCH-1", source="manual") + assert lid2 is not None and lid2 != lid + + +# =========================================================================== +# TC-04 — never-raise: a failing DB -> safe defaults, no exception +# =========================================================================== +def test_tc04_never_raise_on_db_error(monkeypatch): + def boom(*a, **k): + raise RuntimeError("db down") + + monkeypatch.setattr(db, "record_lesson", boom) + monkeypatch.setattr(db, "lessons_recent_dup_exists", lambda *a, **k: False) + monkeypatch.setattr(db, "get_lessons", boom) + monkeypatch.setattr(db, "update_lesson", boom) + monkeypatch.setattr(db, "lessons_snapshot", boom) + + assert lessons.record("gate_failure", work_item_id=_WI) is None + assert lessons.get(work_item_id=_WI) == [] + assert lessons.update(1, status="closed") is False + snap = lessons.snapshot() + assert snap == {"enabled": True} # never-raise -> minimal dict, no exception + + +# =========================================================================== +# TC-05 — kill-switch: lessons_enabled=False -> inert, no DB access +# =========================================================================== +def test_tc05_kill_switch_inert(monkeypatch): + monkeypatch.setattr(cfg.settings, "lessons_enabled", False, raising=False) + + def fail(*a, **k): + raise AssertionError("DB must NOT be touched when kill-switch is off") + + monkeypatch.setattr(db, "record_lesson", fail) + monkeypatch.setattr(db, "get_lessons", fail) + monkeypatch.setattr(db, "update_lesson", fail) + monkeypatch.setattr(db, "lessons_snapshot", fail) + + assert lessons.record("gate_failure", work_item_id=_WI) is None + assert lessons.get(work_item_id=_WI) == [] + assert lessons.update(1, status="closed") is False + assert lessons.snapshot() == {"enabled": False} + + +# =========================================================================== +# TC-06 — get_lessons filters + limit + ORDER BY id DESC +# =========================================================================== +def test_tc06_filters_limit_order(): + # Seed rows directly via the DB helper (bypasses the leaf's auto-dedup). + for i in range(5): + db.record_lesson( + lesson_type="gate_failure", work_item_id=f"ORCH-{i}", repo=_REPO, + status="new", source="auto", + ) + db.record_lesson(lesson_type="merge_hold", work_item_id="ORCH-X", repo="enduro-trails", + status="closed", source="auto") + + # Filter by type. + gf = db.get_lessons(lesson_type="gate_failure") + assert len(gf) == 5 and all(r["lesson_type"] == "gate_failure" for r in gf) + # Filter by status. + assert len(db.get_lessons(status="closed")) == 1 + # Filter by repo. + assert len(db.get_lessons(repo="enduro-trails")) == 1 + # Filter by work_item. + assert len(db.get_lessons(work_item_id="ORCH-3")) == 1 + # LIMIT. + assert len(db.get_lessons(lesson_type="gate_failure", limit=2)) == 2 + # ORDER BY id DESC (newest first). + allr = db.get_lessons(limit=100) + got_ids = [r["id"] for r in allr] + assert got_ids == sorted(got_ids, reverse=True) + + +# =========================================================================== +# TC-07 — update_lesson mutates + stamps updated_at; unknown id safe +# =========================================================================== +def test_tc07_update_and_unknown_id(): + lid = db.record_lesson(lesson_type="deploy_degraded", work_item_id=_WI, repo=_REPO, + status="new", source="auto") + before = db.get_lessons(work_item_id=_WI)[0] + assert before["updated_at"] is None + ok = db.update_lesson( + lid, status="in_progress", attribution="both", target_repo=_REPO, + target_domain="reliability", related_task="ORCH-200", + ) + assert ok is True + after = db.get_lessons(work_item_id=_WI)[0] + assert after["status"] == "in_progress" + assert after["attribution"] == "both" + assert after["related_task"] == "ORCH-200" + assert after["updated_at"] is not None + # Unknown id -> no row changed, no raise. + assert db.update_lesson(999999, status="closed") is False + # Empty update (no recognised fields) -> False, safe. + assert db.update_lesson(lid) is False + + +# =========================================================================== +# TC-07b — auto dedup vs manual always-writes (D4) +# =========================================================================== +def test_tc07b_auto_dedup_and_manual_passthrough(): + a = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="auto") + b = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="auto") + assert a is not None and b is None # second auto deduped in-window + # Manual is never deduped. + m1 = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="manual") + m2 = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="manual") + assert m1 is not None and m2 is not None and m1 != m2 + # Window=0 disables dedup. + import src.config as c + c.settings.lessons_dedup_window_s = 0 + c2 = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="auto") + assert c2 is not None + c.settings.lessons_dedup_window_s = 3600 + + +# =========================================================================== +# TC-08 — wiring: QG rollback to development writes a gate_failure lesson +# =========================================================================== +def test_tc08_gate_failure_autorecord(monkeypatch): + from src import stage_engine as se + + # All side-effecting DB / notifier / plane ops on the rollback path are patched + # to no-ops; only the lessons block reaches the (real tmp) DB — so we assert the + # WIRING (rolled_back_to -> gate_failure lesson) without standing up a full task. + for name in ("notify_stage_change", "plane_notify_stage", "send_telegram", + "set_issue_in_progress", "plane_add_comment", "update_task_stage"): + monkeypatch.setattr(se, name, lambda *a, **k: None, raising=False) + monkeypatch.setattr(se, "extract_test_failures", lambda *a, **k: "", raising=False) + monkeypatch.setattr(se, "_developer_retry_count", lambda *a, **k: 0, raising=False) + monkeypatch.setattr(se, "enqueue_job", lambda *a, **k: 123, raising=False) + + result = se.AdvanceResult() + se._handle_qg_failure_rollbacks( + 99, "testing", _REPO, "ORCH-098", "feature/ORCH-098-fnd", + agent="tester", qg_name="check_tests_passed", reason="2 failed", result=result, + ) + assert result.rolled_back_to == "development" + rows = db.get_lessons(lesson_type="gate_failure", work_item_id="ORCH-098") + assert len(rows) == 1 + r = rows[0] + assert r["stage"] == "testing" + assert r["agent"] == "tester" + assert r["repo"] == _REPO + assert r["source"] == "auto" + assert r["detail"] == "check_tests_passed" + + +# =========================================================================== +# TC-09 — wiring: launcher transient-budget-exhaustion writes a lesson; +# a failing journal never breaks the hot path +# =========================================================================== +def test_tc09_transient_autorecord_and_never_raise(monkeypatch): + from src.agents import launcher as lmod + + launcher = lmod.AgentLauncher() + monkeypatch.setattr(launcher, "_notify_failed", lambda *a, **k: None) + monkeypatch.setattr(launcher, "_record_outcome", lambda *a, **k: None) + monkeypatch.setattr(cfg.settings, "transient_max_attempts", 3, raising=False) + + job_id = db.enqueue_job("developer", _REPO, "task", task_id=42) + job = {"transient_attempts": 3, "task_id": 42, "repo": _REPO} + # Budget exhausted (tattempts >= tmax) -> the failed branch records the lesson. + launcher._finalize_transient(job_id, "developer", 1, 99, job, retry_after=None) + + rows = db.get_lessons(lesson_type="transient_retry") + assert len(rows) == 1 + assert rows[0]["repo"] == _REPO + assert rows[0]["agent"] == "developer" + assert rows[0]["source"] == "auto" + + # never-raise in the hot path: a failing record must not break finalisation. + def boom(*a, **k): + raise RuntimeError("journal down") + + monkeypatch.setattr(db, "record_lesson", boom) + monkeypatch.setattr(db, "lessons_recent_dup_exists", lambda *a, **k: False) + job_id2 = db.enqueue_job("developer", _REPO, "task2", task_id=43) + job2 = {"transient_attempts": 3, "task_id": 43, "repo": _REPO} + # Must NOT raise even though the journal insert blows up. + launcher._finalize_transient(job_id2, "developer", 1, 99, job2, retry_after=None) + + +# =========================================================================== +# TC-10 — GET /lessons + GET /queue block; reads do not mutate +# =========================================================================== +def test_tc10_get_endpoints(monkeypatch): + from fastapi.testclient import TestClient + import src.main as main + + db.record_lesson(lesson_type="gate_failure", work_item_id=_WI, repo=_REPO, + status="new", source="auto") + db.record_lesson(lesson_type="merge_hold", work_item_id="ORCH-2", repo="enduro-trails", + status="closed", source="auto") + + client = TestClient(main.app) + + r = client.get("/lessons") + assert r.status_code == 200 + body = r.json() + assert body["enabled"] is True + assert len(body["lessons"]) == 2 + + # Filters. + r = client.get("/lessons", params={"type": "gate_failure"}) + assert len(r.json()["lessons"]) == 1 + r = client.get("/lessons", params={"repo": "enduro-trails"}) + assert len(r.json()["lessons"]) == 1 + r = client.get("/lessons", params={"limit": 1}) + assert len(r.json()["lessons"]) == 1 + + # Reads do not mutate. + assert db.lessons_snapshot()["total"] == 2 + + # GET /queue carries the read-only lessons block. + q = client.get("/queue") + assert q.status_code == 200 + assert "lessons" in q.json() + assert q.json()["lessons"]["enabled"] is True + assert q.json()["lessons"]["total"] == 2 + + +# =========================================================================== +# TC-11 — POST /lessons (manual) + POST /lessons/{id} (update); kill-switch +# =========================================================================== +def test_tc11_post_endpoints_and_killswitch(monkeypatch): + from fastapi.testclient import TestClient + import src.main as main + + client = TestClient(main.app) + + # Manual create with attribution. + r = client.post("/lessons", json={ + "lesson_type": "process_gap", "work_item_id": _WI, "repo": _REPO, + "attribution": "platform", "target_domain": "quality", "root_cause": "manual note", + }) + assert r.status_code == 200 + lid = r.json()["id"] + assert isinstance(lid, int) + rows = db.get_lessons(work_item_id=_WI) + assert rows[0]["source"] == "manual" + assert rows[0]["attribution"] == "platform" + + # Missing lesson_type -> error, no row. + r = client.post("/lessons", json={"work_item_id": "X"}) + assert r.json()["ok"] is False + + # Update via POST /lessons/{id}. + r = client.post(f"/lessons/{lid}", json={"status": "closed", "related_task": "ORCH-300"}) + assert r.json()["ok"] is True + assert db.get_lessons(work_item_id=_WI)[0]["status"] == "closed" + + # Kill-switch off -> endpoints report {"enabled": false}. + monkeypatch.setattr(cfg.settings, "lessons_enabled", False, raising=False) + assert client.get("/lessons").json() == {"enabled": False, "lessons": []} + assert client.post("/lessons", json={"lesson_type": "x"}).json() == {"enabled": False} + assert client.post(f"/lessons/{lid}", json={"status": "new"}).json() == {"enabled": False} + + +# =========================================================================== +# TC-12 — pipeline invariants structurally untouched +# =========================================================================== +def test_tc12_pipeline_invariants_untouched(): + from src.stages import STAGE_TRANSITIONS + from src.qg.checks import QG_CHECKS + + # The journal must not have added/removed a stage edge or a QG check. + assert "development" in STAGE_TRANSITIONS + assert "deploy" in STAGE_TRANSITIONS + # machine-verdict QG checks still registered (sample of the canon set). + for name in ("check_ci_green", "check_tests_passed", "check_coverage_gate"): + assert name in QG_CHECKS + # The journal is NOT a quality gate — no check named after it. + assert not any("lesson" in k.lower() for k in QG_CHECKS) -- 2.49.1 From eea70551e6380321b9e584034f4ccdcd804d0bb5 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:28:03 +0300 Subject: [PATCH 05/11] reviewer(ET): auto-commit from reviewer run_id=577 --- docs/work-items/ORCH-098/12-review.md | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/work-items/ORCH-098/12-review.md diff --git a/docs/work-items/ORCH-098/12-review.md b/docs/work-items/ORCH-098/12-review.md new file mode 100644 index 0000000..0560cb4 --- /dev/null +++ b/docs/work-items/ORCH-098/12-review.md @@ -0,0 +1,92 @@ +--- +verdict: APPROVED +work_item: ORCH-098 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-098 +version: 1 +--- + +# Review ORCH-098 — FND: машинный журнал уроков + +## Summary +Чистая аддитивная реализация observer-leaf'а `src/lessons.py` + таблица `lessons` строго по образцу +`serial_gate`/`coverage_gate`/`metrics`. Все 9 критериев приёмки (AC-1…AC-9) выполнены, ТЗ (FR-1…FR-6) +реализовано полностью, реализация байт-в-байт соответствует ADR-001 (D1…D6). Инварианты конвейера +не тронуты, документация обновлена в том же PR. Тесты зелёные (13 в `test_lessons.py`, broader sanity +17 passed). P0/P1 findings нет → **APPROVED**. + +## Соответствие ТЗ (ось 1) +- **FR-1 / AC-1, AC-2** ✅ `CREATE TABLE IF NOT EXISTS lessons` в `db.init_db()` со всеми полями + 3 индекса; + колонки атрибуции (`attribution`/`target_repo`/`target_domain`) присутствуют сразу, нуллабельны, + плюс forward-safe `_ensure_column` на старой таблице. TC-01/TC-02 подтверждают идемпотентность и + nullable-атрибуцию. +- **FR-2 / AC-6** ✅ `record()` — kill-switch-first no-op, never-raise обёртка над `db.record_lesson`. +- **FR-3 / AC-3** ✅ Реализованы **4** типа автозаписи (> минимума 2–3): `gate_failure` + (`_handle_qg_failure_rollbacks`), `merge_hold` (`_handle_merge_verify` HOLD), `transient_retry` + (`launcher._finalize_transient` на исчерпании бюджета), `deploy_degraded` (post-deploy DEGRADED). + Каждая врезка обёрнута в локальный `try/except` → ошибка импорта/записи не пробивается в горячий путь. + Переменные контекста (`work_item_id`/`repo`/`branch`/`checks_failed`/`checks_total`) проверены в + scope перед точкой врезки в `run_post_deploy_monitor`. TC-08/TC-09 — интеграционные. +- **FR-4 / AC-4** ✅ `GET /lessons` (фильтры type/status/repo/work_item/limit, всегда 200, read-only) + + read-only ключ `lessons` в `GET /queue`. +- **FR-5 / AC-5** ✅ `POST /lessons` (`source="manual"`, не дедупится) + `POST /lessons/{id}` (update со + стампом `updated_at`). TC-07/TC-11. +- **FR-6 / AC-7** ✅ `lessons_enabled=False` → все функции инертны без обращения к БД, эндпоинты отдают + `{"enabled": false}`. TC-05. + +## Соответствие ADR (ось 2) +- D1 (таблица/индексы/forward-safe), D2 (no repo-scope, kill-switch only), D3 (4 детектора), D4 (дедуп + один indexed-SELECT по `idx_lessons_wi_type`, только `auto`, окно `lessons_dedup_window_s`), D5 + (эндпоинты), D6 (изоляция от гейтов) — реализованы как описано. +- **AC-8 проверен диффом:** `src/stages.py` (`STAGE_TRANSITIONS`) и `src/qg/` (`QG_CHECKS`/`check_*`) — + **не затронуты**; machine-verdict-ключи и схемы существующих таблиц не тронуты. Journal не участвует + в решении гейта (TC-12). +- **Трассировка маркеров:** правок чужих `ORCH-NNN`-инвариантов нет — изменения чисто аддитивные + (новые блоки в choke-point'ах, существующая логика не переписана). Слом инвариантов отсутствует. + +## Качество кода (ось 3) +- never-raise контракт соблюдён на всех публичных функциях leaf'а и во всех 4 врезках; DDL-хелперы + `db.py` корректно открывают/закрывают соединение в `finally` (паттерн `coverage_baseline`). +- Тесты содержательные: idempotency, nullable-атрибуция, never-raise с замоканной падающей БД (TC-04), + kill-switch (TC-05), дедуп vs manual-passthrough (TC-07b), интеграция автозаписи (TC-08/TC-09), + эндпоинты (TC-10/TC-11), инварианты конвейера (TC-12). `pytest tests/test_lessons.py -q` → 13 passed. +- **Регресс-тест (ORCH-019/BR-4):** N/A — задача не багфикс (foundation-feature, метки `Bug` нет, + полный маршрут с `architecture`/ADR). + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- [ ] **Грубость дедупа `transient_retry` в `launcher`.** Врезка в `_finalize_transient` передаёт + `task_id`, но **не** `work_item_id` и не `stage`, тогда как дедуп-ключ D4 = `(work_item_id, + lesson_type, stage)`. Для launcher-уроков оба = `NULL`, поэтому два разных задания, исчерпавших + бюджет транзиентов в окне `lessons_dedup_window_s` (дефолт 1ч), коллапсируют в один ключ + `(NULL, transient_retry, NULL)` → второй урок подавляется (потеря межзадачного сигнала). Реализация + следует букве ADR-D4, но точность дедупа на этом детекторе ниже задуманной. Рекомендация (не блокер): + передавать `work_item_id` (если доступен в `job`) либо включить `task_id` в дедуп-ключ для + `transient_retry`. Сигнал редкий (только на исчерпании бюджета), поэтому влияние ограничено. + +## Документация +Обновлена в том же PR — проверено явно: +- `CLAUDE.md` — новый раздел «Машинный журнал уроков (ORCH-098)» (+38). +- `docs/architecture/README.md` — компонент **Lessons journal**, таблица `lessons` в списке схемы БД, + 3 новые строки API-таблицы (`GET /lessons`, `POST /lessons`, `POST /lessons/{id}`), `lessons` в + описании `GET /queue`. +- `CHANGELOG.md` — запись `feat` (ORCH-098) с разбивкой D1…D5 + регресс. +- ADR: локальный `06-adr/ADR-001-lessons-journal.md` (proposed) + сквозной + `docs/architecture/adr/adr-0033-lessons-journal.md` (существует). +- `README.md` «Известные ограничения» — данный PR не закрывает ни одного пункта витрины (обзорные + доки обновлять не требуется). + +Документация = golden source: изменения `src/` сопровождены полным обновлением доков → P0 «src +изменён, документация не обновлена» **не применяется**. -- 2.49.1 From c01c42e5321e2e8b6db45caf29d04b0779c8b55e Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:30:26 +0300 Subject: [PATCH 06/11] tester(ET): auto-commit from tester run_id=578 --- docs/work-items/ORCH-098/13-test-report.md | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/work-items/ORCH-098/13-test-report.md diff --git a/docs/work-items/ORCH-098/13-test-report.md b/docs/work-items/ORCH-098/13-test-report.md new file mode 100644 index 0000000..b0612ce --- /dev/null +++ b/docs/work-items/ORCH-098/13-test-report.md @@ -0,0 +1,72 @@ +--- +result: PASS +work_item: ORCH-098 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-098 +--- + +# Test Report — ORCH-098 — FND: машинный журнал уроков + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-098-fnd` (ветка `feature/ORCH-098-fnd`) +- Дата: 2026-06-10 +- Review verdict (`12-review.md`): **APPROVED** ✅ + +## Результаты — покрытие тест-плана (`04-test-plan.yaml`) + +Каждый TC из тест-плана сопоставлен с конкретным тестом `tests/test_lessons.py` и с критерием +приёмки `03-acceptance-criteria.md`. + +| TC ID | Тип | Описание (кратко) | AC | Тест | Результат | +|-------|-----|-------------------|----|------|-----------| +| TC-01 | unit | `init_db()` создаёт `lessons` идемпотентно, все поля BR-1 | AC-1 | `test_tc01_table_idempotent_and_fields` | PASS | +| TC-02 | unit | Нуллабельные колонки атрибуции; запись без них + update позже | AC-2 | `test_tc02_attribution_columns_nullable_and_settable` | PASS | +| TC-03 | unit | `record()` вставляет строку (auto/manual), возвращает id, стампит `created_at` | AC-3/AC-5 | `test_tc03_record_inserts_and_returns_id` | PASS | +| TC-04 | unit | never-raise: падающая БД → `None`/`[]`/`{}` без исключения | AC-6 | `test_tc04_never_raise_on_db_error` | PASS | +| TC-05 | unit | kill-switch `lessons_enabled=False` → record/get/update/snapshot инертны | AC-7 | `test_tc05_kill_switch_inert` | PASS | +| TC-06 | unit | `get_lessons` фильтрует type/status/repo/work_item + limit, `ORDER BY id DESC` | AC-4 | `test_tc06_filters_limit_order` | PASS | +| TC-07 | unit | `update_lesson` меняет status/attribution/target_*/related_task + `updated_at`; неизвестный id безопасен | AC-5 | `test_tc07_update_and_unknown_id` | PASS | +| TC-08 | integration | Автозапись gate-fail: откат на development → строка `type=gate_failure` с контекстом | AC-3 | `test_tc08_gate_failure_autorecord` | PASS | +| TC-09 | integration | Автозапись transient/HOLD; сбой записи не ломает горячий путь (never-raise) | AC-3/AC-6 | `test_tc09_transient_autorecord_and_never_raise` | PASS | +| TC-10 | integration | `GET /lessons` → 200 + фильтры; `GET /queue` блок `lessons`; чтение не мутирует | AC-4 | `test_tc10_get_endpoints` | PASS | +| TC-11 | integration | `POST /lessons` (manual+атрибуция), `POST /lessons/{id}` (update); flag=False → `{enabled:false}` | AC-5/AC-7 | `test_tc11_post_endpoints_and_killswitch` | PASS | +| TC-12 | unit | Инварианты конвейера: `STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict неизменны | AC-8 | `test_tc12_pipeline_invariants_untouched` | PASS | + +**Дополнительно** (сверх тест-плана): `test_tc07b_auto_dedup_and_manual_passthrough` (дедуп +`source="auto"` в окне `lessons_dedup_window_s` + manual-passthrough, ADR-001 D4) — PASS. + +Покрытие: **все 12 TC тест-плана выполнены и PASS**; каждый сопоставлен с критерием приёмки +AC-1…AC-8 (AC-9 — верификация тестов/доков, подтверждена зелёным регрессом и review APPROVED). + +## Smoke API (read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` ✅ +- `GET /status` → 200, активные задачи отдаются (ORCH-098 task 86 на `testing`) ✅ +- `GET /queue` → 200; блок **`serial_gate`** присутствует (ORCH-088) ✅; блок `auto_labels` + присутствует ✅; блоки `coverage`/`stop`/`bug_fast_track` присутствуют ✅ +- Прим.: эндпоинт `GET /lessons` и блок `lessons` в `/queue` на прод-контейнере (8500) **отсутствуют + ожидаемо** — прод исполняет ещё не задеплоенный код; новая функциональность верифицирована в + worktree (TC-10/TC-11 зелёные). Это **не** регресс смока: обязательный блок `serial_gate` на месте. + +## Вывод pytest +``` +$ cd /repos/_wt/orchestrator/feature_ORCH-098-fnd && pytest tests/ -v --tb=short +... +================== 1564 passed, 1 warning in 62.81s (0:01:02) ================== + +$ pytest tests/test_lessons.py -v +======================== 13 passed, 1 warning in 1.56s ========================= +``` +(1 warning — устаревший Pydantic class-based `config` в `src/config.py`, не относится к ORCH-098.) + +## Итог +**PASS** — полный регресс `tests/` зелёный (1564 passed), все 12 TC тест-плана выполнены и +сопоставлены с критериями приёмки, smoke read-only (`/health`, `/status`, `/queue` с обязательным +блоком `serial_gate`) в порядке. Review-вердикт APPROVED. Задача готова к переходу на +`deploy-staging`. -- 2.49.1 From 21a47e85d3613a993ba9d522a5615c8bb01d1d78 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:44:34 +0300 Subject: [PATCH 07/11] =?UTF-8?q?fix(lessons):=20resolve=20land-race=20wit?= =?UTF-8?q?h=20ORCH-100=20=E2=80=94=20renumber=20ADR=200033=E2=86=920034?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge-gate auto_rebase_onto_main bounced this branch back: ORCH-100 landed in main first and claimed global ADR number adr-0033 (adr-0033-sidecar-watchdog), while this branch had created adr-0033-lessons-journal. Resolved the genuine land race: - rebased feature/ORCH-098-fnd onto current origin/main (linear history) - resolved docs/architecture/README.md component-list conflict — both the Lessons-journal and Sidecar-watchdog bullets now coexist - renamed docs/architecture/adr/adr-0033-lessons-journal.md → adr-0034-lessons-journal.md (next free global ADR number) + fixed the in-file header - updated all cross-references (CLAUDE.md, README.md, work-item ADR-001, 12-review.md) 0033→0034 for the lessons journal; ORCH-100's adr-0033 (sidecar) left intact - recovered the ORCH-098 CHANGELOG entry silently dropped by the rebase auto-merge (now above ORCH-100, ADR ref corrected to 0034) No code semantics changed; src/** auto-merged cleanly (ORCH-100 did not touch src/**). ruff: n/a locally (CI). pytest tests/ -q: 1630 passed. Refs: ORCH-098 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +++++++ CLAUDE.md | 2 +- ...0033-lessons-journal.md => adr-0034-lessons-journal.md} | 2 +- docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md | 4 ++-- docs/work-items/ORCH-098/12-review.md | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) rename docs/architecture/adr/{adr-0033-lessons-journal.md => adr-0034-lessons-journal.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b65ad4..2108a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Машинный журнал уроков `lessons`** (ORCH-098, `feat`): шаг 1 («Фундамент», F2) эпика саморазвития — формализует свободнотекстовые «уроки» из `memory/` в **машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих ретроспективщика (E2), приоритизатора RICE (E3) и Стрим. Чистый **observer-leaf** `src/lessons.py` (never-raise, kill-switch, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/`update()`/`snapshot()`. **Инвариант:** журнал — наблюдатель, **не** Quality Gate — `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц байт-в-байт не тронуты; enduro не затронут. ADR: `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`, сквозной `docs/architecture/adr/adr-0034-lessons-journal.md`. + - **Таблица (D1, FR-1):** аддитивная идемпотентная `lessons` (`CREATE TABLE IF NOT EXISTS` в `db.init_db()` + три индекса, restart-safe) — контекст (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализ (`root_cause`/`suggestion`), статус (`status`/`related_task`), **колонки атрибуции — сразу и нуллабельно** (`attribution`/`target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже через update; `_ensure_column` форвард-safe на старой таблице) + `source`/`detail`; без `enum`-констрейнтов (слаги forward-compatible). Хелперы `db.record_lesson`/`get_lessons`/`update_lesson`/`lessons_snapshot`/`lessons_recent_dup_exists`. + - **НЕ скоупится по репо (D2):** журнал observer-only → единственный регулятор — глобальный kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`); **`lessons_repos` НЕ вводится**. Recorder пишет уроки про **любой** репо (включая enduro-trails); репо-разрез — на **выборке** (`get(repo=…)`). + - **Автозапись 4 типов (D3, FR-3):** тонкие best-effort врезки (`source="auto"`, never-raise, дедуп) — `gate_failure` (`stage_engine._handle_qg_failure_rollbacks`, откат на `development`), `merge_hold` (`stage_engine._handle_merge_verify` HOLD), `transient_retry` (`launcher._finalize_transient` на исчерпании бюджета ретраев), `deploy_degraded` (post-deploy `DEGRADED → set_repo_freeze`, урок слоя-3 «деплой OK / прод сломан» ET-8). + - **Дедуп (D4):** для `auto` — один indexed-SELECT по `idx_lessons_wi_type`: дубль `(work_item_id, lesson_type, stage)` в окне `lessons_dedup_window_s` (env, дефолт 3600с) → no-op; `manual` не дедупится. + - **Эндпоинты (D5, FR-4/5):** `GET /lessons` (read-only, фильтры `type`/`status`/`repo`/`work_item`/`limit`), `POST /lessons` (ручная запись), `POST /lessons/{id}` (доклассификация/update); read-only ключ `lessons` в `GET /queue`. Выключенный флаг → `{"enabled": false}`. + - **Регресс:** kill-switch `lessons_enabled=False` → полная инертность (no-op без обращения к БД); never-raise на всех публичных функциях/врезках — сбой журнала не роняет конвейер; аддитивно (новая таблица + leaf + эндпоинты + тонкие врезки). Флаги `config.py`: `lessons_enabled`/`lessons_query_limit_default`/`lessons_dedup_window_s`. Тесты `tests/test_lessons.py` (TC-01…TC-12, unit+integration). - **FND/F1b: sidecar-watchdog — мозг мониторинга в отдельном контейнере** (ORCH-100, `feat`): новая папка `watchdog/` (тонкий **Python-3.12-stdlib-only** демон) + сервис `orchestrator-watchdog` в `docker-compose.yml` (`network_mode: host`, read-only `docker.sock`, `mem_limit: 128m`). Вторая половина пары наблюдаемости домена 0: F1a (ORCH-099) отдаёт `GET /metrics` (сырьё), F1b — **мозг**, который это сырьё читает, дополняет внешними сигналами (хост/контейнеры/зависимости) и превращает в **алерты** через **собственный** независимый Telegram-канал. **`src/**` НЕ изменён** — F1b потребитель `/metrics`; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД орка — байт-в-байт. Аддитивно, под kill-switch `WATCHDOG_ENABLED`, строго read-only к наблюдаемому (self-hosting-безопасно). ADR: `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`, сквозной `docs/architecture/adr/adr-0033-sidecar-watchdog.md`. - **fix(test): изоляция `settings.runs_dir` в conftest** — устранена амбиентная prod-зависимость, валившая `test_queue.py::TestRetry::test_finalize_job_requeue_then_fail` в self-hosting-окружении (TC-14 «full tests/ regression green»). `launcher._finalize_job` классифицирует падение по хвосту `/.log`; `runs_dir` по умолчанию = живой prod-каталог `/app/data/runs`, где на хосте накоплены РЕАЛЬНЫЕ логи агентов (`2.log` содержит `429` → 'transient'), поэтому тест с литеральным `run_id=2` читал чужой prod-лог и получал requeue вместо `failed`. Новый autouse-фикстур `_isolate_runs_dir` в `tests/conftest.py` (по образцу `_no_telegram`/`_disable_merge_verify`) перенаправляет `runs_dir` в пер-тестовый tmp → `_run_log_path()` указывает на несуществующий файл → `classify_log_file()` отдаёт документированный дефолт 'permanent'. Детерминизм всей сюты восстановлен (1617 passed); `src/**` не тронут. - **Стек (D1):** Python 3.12 stdlib-only на `python:3.12-slim` — `urllib` (HTTP `/metrics` + пинги + Telegram POST), сырой HTTP-over-unix-socket для read-only `docker.sock` (БЕЗ pip-пакета `docker`), `shutil.disk_usage`/`/proc/meminfo` для хоста. Нет дерева зависимостей (тонкость, C-3). Отдельный образ `watchdog/Dockerfile` (build-контекст = корень репо; `src/**` НЕ копируется — изоляция C-1). diff --git a/CLAUDE.md b/CLAUDE.md index c1ab1e4..9595588 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,7 +271,7 @@ machine-verdict/схемы существующих таблиц байт-в-б безопасный дефолт) — сбой журнала не роняет конвейер. Self-hosting-безопасно: только читает/пишет свою таблицу, не деплоит/не рестартит прод/не трогает `main`/без процессов/сети. Детали — `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`, - `docs/architecture/adr/adr-0033-lessons-journal.md`. + `docs/architecture/adr/adr-0034-lessons-journal.md`. ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) diff --git a/docs/architecture/adr/adr-0033-lessons-journal.md b/docs/architecture/adr/adr-0034-lessons-journal.md similarity index 99% rename from docs/architecture/adr/adr-0033-lessons-journal.md rename to docs/architecture/adr/adr-0034-lessons-journal.md index 25ac65f..379ff42 100644 --- a/docs/architecture/adr/adr-0033-lessons-journal.md +++ b/docs/architecture/adr/adr-0034-lessons-journal.md @@ -7,7 +7,7 @@ created_at: 2026-06-10 model_used: claude-opus-4-8 --- -# adr-0033: Машинный журнал уроков — таблица `lessons` + observer-leaf (ORCH-098) +# adr-0034: Машинный журнал уроков — таблица `lessons` + observer-leaf (ORCH-098) ## Статус Proposed diff --git a/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md b/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md index 2b20c91..03357dc 100644 --- a/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md +++ b/docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md @@ -11,7 +11,7 @@ model_used: claude-opus-4-8 Work Item: **ORCH-098** — FND: машинный журнал уроков (структурированная база отклонений конвейера) Стадия: **architecture** -Сквозная регистрация: **`docs/architecture/adr/adr-0033-lessons-journal.md`** (решение +Сквозная регистрация: **`docs/architecture/adr/adr-0034-lessons-journal.md`** (решение кросс-каттинговое: новый компонент + новая таблица на общей прод-БД + фундамент эпика саморазвития). @@ -238,7 +238,7 @@ LIMIT 1; - 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` +- Сквозной ADR: `docs/architecture/adr/adr-0034-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`. diff --git a/docs/work-items/ORCH-098/12-review.md b/docs/work-items/ORCH-098/12-review.md index 0560cb4..0414999 100644 --- a/docs/work-items/ORCH-098/12-review.md +++ b/docs/work-items/ORCH-098/12-review.md @@ -84,7 +84,7 @@ version: 1 описании `GET /queue`. - `CHANGELOG.md` — запись `feat` (ORCH-098) с разбивкой D1…D5 + регресс. - ADR: локальный `06-adr/ADR-001-lessons-journal.md` (proposed) + сквозной - `docs/architecture/adr/adr-0033-lessons-journal.md` (существует). + `docs/architecture/adr/adr-0034-lessons-journal.md` (существует). - `README.md` «Известные ограничения» — данный PR не закрывает ни одного пункта витрины (обзорные доки обновлять не требуется). -- 2.49.1 From de009822c0538c31dd62493e29f861b66a256dcc Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:49:49 +0300 Subject: [PATCH 08/11] reviewer(ET): auto-commit from reviewer run_id=581 --- docs/work-items/ORCH-098/12-review.md | 103 ++++++++++---------------- 1 file changed, 41 insertions(+), 62 deletions(-) diff --git a/docs/work-items/ORCH-098/12-review.md b/docs/work-items/ORCH-098/12-review.md index 0414999..21c4248 100644 --- a/docs/work-items/ORCH-098/12-review.md +++ b/docs/work-items/ORCH-098/12-review.md @@ -14,79 +14,58 @@ version: 1 # Review ORCH-098 — FND: машинный журнал уроков ## Summary -Чистая аддитивная реализация observer-leaf'а `src/lessons.py` + таблица `lessons` строго по образцу -`serial_gate`/`coverage_gate`/`metrics`. Все 9 критериев приёмки (AC-1…AC-9) выполнены, ТЗ (FR-1…FR-6) -реализовано полностью, реализация байт-в-байт соответствует ADR-001 (D1…D6). Инварианты конвейера -не тронуты, документация обновлена в том же PR. Тесты зелёные (13 в `test_lessons.py`, broader sanity -17 passed). P0/P1 findings нет → **APPROVED**. -## Соответствие ТЗ (ось 1) -- **FR-1 / AC-1, AC-2** ✅ `CREATE TABLE IF NOT EXISTS lessons` в `db.init_db()` со всеми полями + 3 индекса; - колонки атрибуции (`attribution`/`target_repo`/`target_domain`) присутствуют сразу, нуллабельны, - плюс forward-safe `_ensure_column` на старой таблице. TC-01/TC-02 подтверждают идемпотентность и - nullable-атрибуцию. -- **FR-2 / AC-6** ✅ `record()` — kill-switch-first no-op, never-raise обёртка над `db.record_lesson`. -- **FR-3 / AC-3** ✅ Реализованы **4** типа автозаписи (> минимума 2–3): `gate_failure` - (`_handle_qg_failure_rollbacks`), `merge_hold` (`_handle_merge_verify` HOLD), `transient_retry` - (`launcher._finalize_transient` на исчерпании бюджета), `deploy_degraded` (post-deploy DEGRADED). - Каждая врезка обёрнута в локальный `try/except` → ошибка импорта/записи не пробивается в горячий путь. - Переменные контекста (`work_item_id`/`repo`/`branch`/`checks_failed`/`checks_total`) проверены в - scope перед точкой врезки в `run_post_deploy_monitor`. TC-08/TC-09 — интеграционные. -- **FR-4 / AC-4** ✅ `GET /lessons` (фильтры type/status/repo/work_item/limit, всегда 200, read-only) + - read-only ключ `lessons` в `GET /queue`. -- **FR-5 / AC-5** ✅ `POST /lessons` (`source="manual"`, не дедупится) + `POST /lessons/{id}` (update со - стампом `updated_at`). TC-07/TC-11. -- **FR-6 / AC-7** ✅ `lessons_enabled=False` → все функции инертны без обращения к БД, эндпоинты отдают - `{"enabled": false}`. TC-05. +Реализация полностью соответствует ТЗ (`02-trz.md`), критериям приёмки (`03-acceptance-criteria.md`) +и ADR-001/adr-0034. Введён чистый observer-leaf `src/lessons.py` (never-raise, единственный +kill-switch `lessons_enabled`, без repo-скоупа — по решению D2), аддитивная идемпотентная таблица +`lessons` с нуллабельными колонками атрибуции сразу (NFR-6, требование Славы 10.06), 4 типа +автозаписи best-effort, дедуп для `auto`, три HTTP-эндпоинта + блок `lessons` в `GET /queue`. -## Соответствие ADR (ось 2) -- D1 (таблица/индексы/forward-safe), D2 (no repo-scope, kill-switch only), D3 (4 детектора), D4 (дедуп - один indexed-SELECT по `idx_lessons_wi_type`, только `auto`, окно `lessons_dedup_window_s`), D5 - (эндпоинты), D6 (изоляция от гейтов) — реализованы как описано. -- **AC-8 проверен диффом:** `src/stages.py` (`STAGE_TRANSITIONS`) и `src/qg/` (`QG_CHECKS`/`check_*`) — - **не затронуты**; machine-verdict-ключи и схемы существующих таблиц не тронуты. Journal не участвует - в решении гейта (TC-12). -- **Трассировка маркеров:** правок чужих `ORCH-NNN`-инвариантов нет — изменения чисто аддитивные - (новые блоки в choke-point'ах, существующая логика не переписана). Слом инвариантов отсутствует. +**Инварианты конвейера не тронуты (AC-8):** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` +(`QG_CHECKS`/`check_*`), `src/merge_gate.py`, machine-verdict-ключи и схемы существующих таблиц — +**диффом не затронуты** (подтверждено `git diff --name-only`). `tests/test_lessons.py` (TC-01…TC-12, +13 тестов) — **зелёный** локально. Документация обновлена в том же PR. -## Качество кода (ось 3) -- never-raise контракт соблюдён на всех публичных функциях leaf'а и во всех 4 врезках; DDL-хелперы - `db.py` корректно открывают/закрывают соединение в `finally` (паттерн `coverage_baseline`). -- Тесты содержательные: idempotency, nullable-атрибуция, never-raise с замоканной падающей БД (TC-04), - kill-switch (TC-05), дедуп vs manual-passthrough (TC-07b), интеграция автозаписи (TC-08/TC-09), - эндпоинты (TC-10/TC-11), инварианты конвейера (TC-12). `pytest tests/test_lessons.py -q` → 13 passed. -- **Регресс-тест (ORCH-019/BR-4):** N/A — задача не багфикс (foundation-feature, метки `Bug` нет, - полный маршрут с `architecture`/ADR). +Все findings — P2/P3 (advisory), блокеров нет. ## Findings ### P0 — Blocker -- нет +- Нет. ### P1 — Must fix -- нет +- Нет. ### P2 — Should fix -- [ ] **Грубость дедупа `transient_retry` в `launcher`.** Врезка в `_finalize_transient` передаёт - `task_id`, но **не** `work_item_id` и не `stage`, тогда как дедуп-ключ D4 = `(work_item_id, - lesson_type, stage)`. Для launcher-уроков оба = `NULL`, поэтому два разных задания, исчерпавших - бюджет транзиентов в окне `lessons_dedup_window_s` (дефолт 1ч), коллапсируют в один ключ - `(NULL, transient_retry, NULL)` → второй урок подавляется (потеря межзадачного сигнала). Реализация - следует букве ADR-D4, но точность дедупа на этом детекторе ниже задуманной. Рекомендация (не блокер): - передавать `work_item_id` (если доступен в `job`) либо включить `task_id` в дедуп-ключ для - `transient_retry`. Сигнал редкий (только на исчерпании бюджета), поэтому влияние ограничено. +- [ ] **Кросс-задачный дедуп `transient_retry` теряет сигнал.** Врезка в + `launcher._finalize_transient` (`src/agents/launcher.py:~1024`) передаёт `task_id`, но **не** + `work_item_id` и **не** `stage` → ключ дедупа `db.lessons_recent_dup_exists` становится + `(work_item_id IS NULL, lesson_type='transient_retry', stage IS NULL)`. В окне + `lessons_dedup_window_s` (дефолт 1ч) **разные** задачи, исчерпавшие бюджет ретраев, схлопываются в + одну запись — теряется урок про вторую задачу. Поскольку `task_id` локально доступен, дедуп-ключ + стоило бы доопределять им при `work_item_id is None` (или включать `task_id` в ключ дедупа). + Это observer/best-effort (не влияет на конвейер, AC-3 формально выполнен — 4 типа автозаписи + работают), потому не блокер, но ослабляет ценность самого сигнала, ради которого фича вводится. + Ссылка: ADR-001 D4 («ключ `work_item_id+stage+lesson_type`»). + +### P3 — Nice to have +- [ ] **Мелкая неточность ADR vs код.** `06-adr/ADR-001` (D3, таблица) и `adr-0034` указывают + choke-point `merge_hold` как `merge_gate._handle_merge_verify`, фактически `_handle_merge_verify` + живёт в `src/stage_engine.py` (туда и врезан `merge_hold`; `merge_gate.py` диффом не тронут). + Функционально корректно; рекомендуется поправить адрес в ADR для трассировки. Также + `transient_retry` в `merge_gate` (merge-retry exhausted) не реализован — но ADR формулирует это как + «**and/or** launcher», т.е. опционально; реализация через launcher достаточна. ## Документация -Обновлена в том же PR — проверено явно: -- `CLAUDE.md` — новый раздел «Машинный журнал уроков (ORCH-098)» (+38). -- `docs/architecture/README.md` — компонент **Lessons journal**, таблица `lessons` в списке схемы БД, - 3 новые строки API-таблицы (`GET /lessons`, `POST /lessons`, `POST /lessons/{id}`), `lessons` в - описании `GET /queue`. -- `CHANGELOG.md` — запись `feat` (ORCH-098) с разбивкой D1…D5 + регресс. -- ADR: локальный `06-adr/ADR-001-lessons-journal.md` (proposed) + сквозной - `docs/architecture/adr/adr-0034-lessons-journal.md` (существует). -- `README.md` «Известные ограничения» — данный PR не закрывает ни одного пункта витрины (обзорные - доки обновлять не требуется). -Документация = golden source: изменения `src/` сопровождены полным обновлением доков → P0 «src -изменён, документация не обновлена» **не применяется**. +**Обновлена полностью в том же PR — ось «документация» PASS:** +- `CLAUDE.md` — добавлен раздел «Машинный журнал уроков (ORCH-098)» (D1–D5, флаги, инвариант). +- `docs/architecture/README.md` — компонент «Lessons journal», строка таблицы `lessons` в разделе + схемы БД, три новых эндпоинта в таблице API, обновлена строка `GET /queue` (`+ lessons (ORCH-098)`). +- `docs/architecture/adr/adr-0034-lessons-journal.md` — сквозной ADR (новый). +- `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md` — локальный ADR (присутствует). +- `CHANGELOG.md` — запись `[Unreleased]` с разбивкой D1–D5 + регресс. +- `README.md` «Известные ограничения» — пунктов, закрываемых этой задачей, нет (ORCH-079 N/A). + +Изменение `src/` ⇒ требование «документация = golden source» выполнено; основание для +`REQUEST_CHANGES` по оси документации отсутствует. -- 2.49.1 From 917acf3e1e5d66d481ff888edd0e7532c8407653 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:52:53 +0300 Subject: [PATCH 09/11] tester(ET): auto-commit from tester run_id=582 --- docs/work-items/ORCH-098/13-test-report.md | 102 ++++++++++++--------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/docs/work-items/ORCH-098/13-test-report.md b/docs/work-items/ORCH-098/13-test-report.md index b0612ce..8570b32 100644 --- a/docs/work-items/ORCH-098/13-test-report.md +++ b/docs/work-items/ORCH-098/13-test-report.md @@ -1,5 +1,5 @@ --- -result: PASS +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE work_item: ORCH-098 stage: testing author_agent: tester @@ -14,59 +14,73 @@ work_item_id: ORCH-098 ## Окружение - Python: 3.12.13 -- pytest: 8.3.3 -- Worktree: `/repos/_wt/orchestrator/feature_ORCH-098-fnd` (ветка `feature/ORCH-098-fnd`) +- pytest: 8.3.3 (pytest-cov 5.0.0, anyio 4.13.0, asyncio 0.23.8) +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-098-fnd/` (ветка `feature/ORCH-098-fnd`) - Дата: 2026-06-10 -- Review verdict (`12-review.md`): **APPROVED** ✅ + +## Предусловия +- Review-вердикт (`12-review.md`): **APPROVED** (блокеров нет, все findings P2/P3 advisory). ✅ +- Smoke API (read-only, prod 8500): + - `GET /health` → `{"status":"ok","service":"orchestrator"}` ✅ + - `GET /status` → `200`, активные задачи отдаются (ORCH-098 в стадии `testing`). ✅ + - `GET /queue` → `200`; присутствует блок **`serial_gate`** (ORCH-088) ✅ и **`auto_labels`** + (ORCH-089) ✅ в полезной нагрузке — смок-регресса нет. + - Примечание: прод-контейнер 8500 несёт ещё не задеплоенный код (без блока `lessons` в `/queue`) — + это ожидаемо (ORCH-098 не выкатан в прод), на смок-вердикт не влияет. ## Результаты — покрытие тест-плана (`04-test-plan.yaml`) -Каждый TC из тест-плана сопоставлен с конкретным тестом `tests/test_lessons.py` и с критерием -приёмки `03-acceptance-criteria.md`. +Прогон: `cd /repos/_wt/orchestrator/feature_ORCH-098-fnd && pytest tests/ -v --tb=short`. +Все TC из тест-плана исполнены и сопоставлены с критериями приёмки (`03-acceptance-criteria.md`). -| TC ID | Тип | Описание (кратко) | AC | Тест | Результат | -|-------|-----|-------------------|----|------|-----------| +| TC ID | Тип | Описание | AC | Тест (`tests/test_lessons.py`) | Результат | +|-------|-----|----------|----|--------------------------------|-----------| | TC-01 | unit | `init_db()` создаёт `lessons` идемпотентно, все поля BR-1 | AC-1 | `test_tc01_table_idempotent_and_fields` | PASS | -| TC-02 | unit | Нуллабельные колонки атрибуции; запись без них + update позже | AC-2 | `test_tc02_attribution_columns_nullable_and_settable` | PASS | -| TC-03 | unit | `record()` вставляет строку (auto/manual), возвращает id, стампит `created_at` | AC-3/AC-5 | `test_tc03_record_inserts_and_returns_id` | PASS | -| TC-04 | unit | never-raise: падающая БД → `None`/`[]`/`{}` без исключения | AC-6 | `test_tc04_never_raise_on_db_error` | PASS | -| TC-05 | unit | kill-switch `lessons_enabled=False` → record/get/update/snapshot инертны | AC-7 | `test_tc05_kill_switch_inert` | PASS | -| TC-06 | unit | `get_lessons` фильтрует type/status/repo/work_item + limit, `ORDER BY id DESC` | AC-4 | `test_tc06_filters_limit_order` | PASS | -| TC-07 | unit | `update_lesson` меняет status/attribution/target_*/related_task + `updated_at`; неизвестный id безопасен | AC-5 | `test_tc07_update_and_unknown_id` | PASS | -| TC-08 | integration | Автозапись gate-fail: откат на development → строка `type=gate_failure` с контекстом | AC-3 | `test_tc08_gate_failure_autorecord` | PASS | -| TC-09 | integration | Автозапись transient/HOLD; сбой записи не ломает горячий путь (never-raise) | AC-3/AC-6 | `test_tc09_transient_autorecord_and_never_raise` | PASS | -| TC-10 | integration | `GET /lessons` → 200 + фильтры; `GET /queue` блок `lessons`; чтение не мутирует | AC-4 | `test_tc10_get_endpoints` | PASS | -| TC-11 | integration | `POST /lessons` (manual+атрибуция), `POST /lessons/{id}` (update); flag=False → `{enabled:false}` | AC-5/AC-7 | `test_tc11_post_endpoints_and_killswitch` | PASS | -| TC-12 | unit | Инварианты конвейера: `STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict неизменны | AC-8 | `test_tc12_pipeline_invariants_untouched` | PASS | +| TC-02 | unit | Нуллабельные колонки атрибуции `attribution/target_repo/target_domain`, update проставляет позже | AC-2 | `test_tc02_attribution_columns_nullable_and_settable` | PASS | +| TC-03 | unit | `record()` вставляет строку (source auto/manual), возвращает id, `created_at` заполнен | AC-3/AC-5 | `test_tc03_record_inserts_and_returns_id` | PASS | +| TC-04 | unit | never-raise при падающей БД: `record/get/update/snapshot` → `None/[]/{}` без исключения | AC-6 | `test_tc04_never_raise_on_db_error` | PASS | +| TC-05 | unit | kill-switch `lessons_enabled=False` — инертность (no-op, без БД) | AC-7 | `test_tc05_kill_switch_inert` | PASS | +| TC-06 | unit | `get_lessons` фильтрует type/status/repo/work_item, limit, `ORDER BY id DESC` | AC-4 | `test_tc06_filters_limit_order` | PASS | +| TC-07 | unit | `update_lesson` меняет status/attribution/target_*/related_task + `updated_at`; неизв. id безопасен | AC-5 | `test_tc07_update_and_unknown_id` | PASS | +| TC-07b | unit | (доп.) дедуп `source=auto` в окне; `source=manual` всегда проходит | AC-3/AC-5 | `test_tc07b_auto_dedup_and_manual_passthrough` | PASS | +| TC-08 | integration | Автозапись gate-fail: откат в `_handle_qg_failure_rollbacks` → строка `gate_failure` с контекстом | AC-3 | `test_tc08_gate_failure_autorecord` | PASS | +| TC-09 | integration | Автозапись transient/HOLD: транзиент-ветка пишет урок; сбой записи не ломает горячий путь | AC-3/AC-6 | `test_tc09_transient_autorecord_and_never_raise` | PASS | +| TC-10 | integration | `GET /lessons` → 200 с фильтрами; `GET /queue` несёт блок `lessons`; чтение не мутирует | AC-4 | `test_tc10_get_endpoints` | PASS | +| TC-11 | integration | `POST /lessons` (manual+атрибуция), `POST /lessons/{id}` обновляет; при выключенном флаге `{enabled:false}` | AC-5/AC-7 | `test_tc11_post_endpoints_and_killswitch` | PASS | +| TC-12 | unit | Инварианты конвейера не тронуты: `STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict неизменны | AC-8 | `test_tc12_pipeline_invariants_untouched` | PASS | -**Дополнительно** (сверх тест-плана): `test_tc07b_auto_dedup_and_manual_passthrough` (дедуп -`source="auto"` в окне `lessons_dedup_window_s` + manual-passthrough, ADR-001 D4) — PASS. - -Покрытие: **все 12 TC тест-плана выполнены и PASS**; каждый сопоставлен с критерием приёмки -AC-1…AC-8 (AC-9 — верификация тестов/доков, подтверждена зелёным регрессом и review APPROVED). - -## Smoke API (read-only) -- `GET /health` → `{"status":"ok","service":"orchestrator"}` ✅ -- `GET /status` → 200, активные задачи отдаются (ORCH-098 task 86 на `testing`) ✅ -- `GET /queue` → 200; блок **`serial_gate`** присутствует (ORCH-088) ✅; блок `auto_labels` - присутствует ✅; блоки `coverage`/`stop`/`bug_fast_track` присутствуют ✅ -- Прим.: эндпоинт `GET /lessons` и блок `lessons` в `/queue` на прод-контейнере (8500) **отсутствуют - ожидаемо** — прод исполняет ещё не задеплоенный код; новая функциональность верифицирована в - worktree (TC-10/TC-11 зелёные). Это **не** регресс смока: обязательный блок `serial_gate` на месте. +**Итог покрытия:** 12/12 TC тест-плана исполнены и сопоставлены с AC-1…AC-9 → PASS. +AC-9 (полный регресс зелёный + новый `test_lessons.py`) подтверждён прогоном ниже. ## Вывод pytest -``` -$ cd /repos/_wt/orchestrator/feature_ORCH-098-fnd && pytest tests/ -v --tb=short -... -================== 1564 passed, 1 warning in 62.81s (0:01:02) ================== -$ pytest tests/test_lessons.py -v -======================== 13 passed, 1 warning in 1.56s ========================= +Полный регресс (`tests/`): +``` +================== 1630 passed, 1 warning in 71.78s (0:01:11) ================== +``` +(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, не связан с ORCH-098, +предсуществующий.) + +Целевой модуль (`tests/test_lessons.py`): +``` +collected 13 items +tests/test_lessons.py::test_tc01_table_idempotent_and_fields PASSED [ 7%] +tests/test_lessons.py::test_tc02_attribution_columns_nullable_and_settable PASSED [ 15%] +tests/test_lessons.py::test_tc03_record_inserts_and_returns_id PASSED [ 23%] +tests/test_lessons.py::test_tc04_never_raise_on_db_error PASSED [ 30%] +tests/test_lessons.py::test_tc05_kill_switch_inert PASSED [ 38%] +tests/test_lessons.py::test_tc06_filters_limit_order PASSED [ 46%] +tests/test_lessons.py::test_tc07_update_and_unknown_id PASSED [ 53%] +tests/test_lessons.py::test_tc07b_auto_dedup_and_manual_passthrough PASSED [ 61%] +tests/test_lessons.py::test_tc08_gate_failure_autorecord PASSED [ 69%] +tests/test_lessons.py::test_tc09_transient_autorecord_and_never_raise PASSED [ 76%] +tests/test_lessons.py::test_tc10_get_endpoints PASSED [ 84%] +tests/test_lessons.py::test_tc11_post_endpoints_and_killswitch PASSED [ 92%] +tests/test_lessons.py::test_tc12_pipeline_invariants_untouched PASSED [100%] +======================== 13 passed, 1 warning in 1.55s ========================= ``` -(1 warning — устаревший Pydantic class-based `config` в `src/config.py`, не относится к ORCH-098.) ## Итог -**PASS** — полный регресс `tests/` зелёный (1564 passed), все 12 TC тест-плана выполнены и -сопоставлены с критериями приёмки, smoke read-only (`/health`, `/status`, `/queue` с обязательным -блоком `serial_gate`) в порядке. Review-вердикт APPROVED. Задача готова к переходу на -`deploy-staging`. +**PASS** — полный регресс зелёный (1630 passed), все 12 TC тест-плана исполнены и сопоставлены +с критериями приёмки, smoke API read-only (`/health`/`/status`/`/queue`) в норме (блоки +`serial_gate` и `auto_labels` присутствуют). Задача готова к переходу на `deploy-staging`. -- 2.49.1 From 66700123ac8bef7a626b69ee228e472a1b8dcce2 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 10:55:51 +0300 Subject: [PATCH 10/11] =?UTF-8?q?docs(ORCH-098):=20staging=20gate=20SUCCES?= =?UTF-8?q?S=20=E2=80=94=2015-staging-log.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging suite (docker exec orchestrator-staging, port 8501) exit 0. All REAL checks green; C9a/C9b INFRA-WAIVED (ORCH-061). Co-Authored-By: Claude Opus 4.8 --- docs/work-items/ORCH-098/15-staging-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/work-items/ORCH-098/15-staging-log.md b/docs/work-items/ORCH-098/15-staging-log.md index b645cfd..d1b4a5e 100644 --- a/docs/work-items/ORCH-098/15-staging-log.md +++ b/docs/work-items/ORCH-098/15-staging-log.md @@ -6,7 +6,7 @@ author_agent: deployer status: success created_at: 2026-06-10 model_used: claude-opus-4-8 -timestamp: 2026-06-10T07:32:51Z +timestamp: 2026-06-10T07:55:10Z base_url: http://localhost:8501 --- -- 2.49.1 From 4203d9397829d4cabedbb16802ba122e5838ad05 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Wed, 10 Jun 2026 11:02:22 +0300 Subject: [PATCH 11/11] deploy(ORCH-036): finalize SUCCESS for ORCH-098 --- docs/work-items/ORCH-098/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-098/14-deploy-log.md diff --git a/docs/work-items/ORCH-098/14-deploy-log.md b/docs/work-items/ORCH-098/14-deploy-log.md new file mode 100644 index 0000000..04998ad --- /dev/null +++ b/docs/work-items/ORCH-098/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-098 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. -- 2.49.1