Compare commits
21 Commits
d9bb8d5fe3
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4d265cb5 | ||
|
|
a16196d68c | ||
| 9b3490ceaa | |||
| 3c407397da | |||
| a6d0ba51c0 | |||
| f7488e9536 | |||
| 0b5fede802 | |||
| cc2f1885e8 | |||
| c9be0eb4c9 | |||
| 21bde85708 | |||
| 7d61c820a7 | |||
|
|
69f493fec5 | ||
| dd4aaebe84 | |||
| f645090e4d | |||
| ee4773f5b0 | |||
| 4597a8471d | |||
| b478b38df5 | |||
| 99cafefba6 | |||
| 85cfce451f | |||
| a23d4c0971 | |||
|
|
49fad5e458 |
14
.env.example
14
.env.example
@@ -107,6 +107,20 @@ ORCH_PREMERGE_REBASE_ALWAYS=true
|
||||
# cache them into job_deps (the scheduler then reads only the DB).
|
||||
ORCH_TASK_DEPS_ENABLED=true
|
||||
ORCH_TASK_DEPS_SOURCE=db
|
||||
# ORCH-088 (Stage 1, serial e2e): per-repo serial gate. A NEW task's analyst-job does
|
||||
# NOT enter analysis (no branch cut, no analyst) while the same repo has an EARLIER
|
||||
# unfinished task (FIFO, tasks.id < the job's task) OR the repo is frozen. The branch
|
||||
# cut is DEFERRED from start_pipeline to the analyst-job claim so its base is a fresh
|
||||
# origin/main already containing the predecessor (anti-stale-base). Gate lives in
|
||||
# claim_next_job (offline hot-path, fail-OPEN on error); freeze (FR-5) is a durable
|
||||
# repo_freeze row set on post-deploy DEGRADED, cleared manually via
|
||||
# POST /serial-gate/unfreeze?repo=<repo>. Leaf src/serial_gate.py (never-raise).
|
||||
# SERIAL_GATE_ENABLED=false -> claim AND start_pipeline are 1:1 as before ORCH-088.
|
||||
# SERIAL_GATE_REPOS (CSV) -> scope; EMPTY = ALL repos (not self-hosting-only).
|
||||
# SERIAL_GATE_FREEZE_ENABLED=false -> the rollback-freeze layer is off (not set/read).
|
||||
ORCH_SERIAL_GATE_ENABLED=true
|
||||
ORCH_SERIAL_GATE_REPOS=
|
||||
ORCH_SERIAL_GATE_FREEZE_ENABLED=true
|
||||
# ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in
|
||||
# advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic
|
||||
# merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Work item: ORCH-061
|
||||
Work item: ORCH-088
|
||||
Repo: orchestrator
|
||||
Branch: feature/ORCH-061-bug-deploy-staging-development
|
||||
Branch: feature/ORCH-088-orch-88-10-20
|
||||
Stage: development
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -3,6 +3,19 @@
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
- **Авто-режим по лейблам: autoApprove (авто-BRD) + autoDeploy (авто-деплой)** (ORCH-089, `feat`): сняты **два** человеческих гейта конвейера, тормозящих пакетный автономный прогон (эпик ORCH-088) — гейт BRD (`analysis`: ручной `Approved`) и гейт прод-деплоя (`deploy` Phase A: ручной `Confirm Deploy`, ORCH-059). Решение выборочно (лейбл Plane на задаче), декларативно, обратимо и **не трогает ни одной технической проверки**. Аддитивно по образцу условных под-гейтов (ORCH-035/043/058/088): leaf `src/labels.py` (never-raise) + две точечные врезки + флаги; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД — **без изменений**.
|
||||
- **`autoApprove`** → врезка в `stage_engine._handle_analysis_approved_flow` (ветка `files_ok`) ПОСЛЕ `In Review`+коммента: `set_issue_approved` (индикация) + лог/Telegram/Plane-коммент + `advance_stage(..., finished_agent=None)` — **тот же путь, что человеческий Approved** (`approved-via-status` → `analysis → architecture` + `mark_brd_review_ended`). Без дублирования переходной логики; re-entrancy безопасна (вложенный вызов идёт с `finished_agent=None`, не входит в analyst-ветку).
|
||||
- **`autoDeploy`** → врезка в `stage_engine._handle_self_deploy_phase_a` сразу после advance на `deploy`+`clear_state` (ДО «ask-human»): лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b(...)` (idempotency-маркер `INITIATED`, статус `Deploying`, finalizer). Пропускаются лишь индикативно-человеческие шаги (`APPROVE_REQUESTED`+`Awaiting Deploy`+«смените на Confirm Deploy»). **BR-5 структурно:** Phase A достигается только после зелёных под-гейтов ребра `deploy-staging → deploy` (security → merge-gate → image-freshness → staging) → autoDeploy физически не деплоит сломанное.
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` (поле `labels` issue; `None` при ошибке ≠ `[]`) + `get_project_labels` (`{normalized_name→uuid}`, TTL-кэш `auto_label_states_ttl_s` по образцу `get_project_states`); сопоставление по нормализованному имени (`strip().casefold()`), неоднозначность (две метки → одно нормализованное имя) → сентинел `__AMBIGUOUS__` → «нет лейбла». Новый сеттер `set_issue_approved` (ключ `approved` уже в `_DEFAULT_STATES`). Источник истины — Plane API, не payload вебхука.
|
||||
- **Флаги** (`config.py`): `auto_label_enabled` (kill-switch), `auto_approve_label`/`auto_deploy_label`, `auto_label_repos` (CSV; **пусто → self-hosting only**), `auto_label_states_ttl_s`. `applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label` (сеть) — только при `applies==True` → при выключенном флаге нулевой сетевой оверхед, нулевая регрессия для enduro (AC-8).
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность → «нет авто» → ручной гейт (never-raise, AC-6). **Прозрачность (AC-7):** лог + Telegram + Plane-коммент + live-карточка через штатный advance. Read-only блок `auto_labels` в `GET /queue`.
|
||||
- **Инфра-предусловие:** создать лейблы `autoApprove`/`autoDeploy` в Plane-проекте ORCH (labels API); их отсутствие = `has_label` False = ручной режим (fail-safe). Детали — `docs/work-items/ORCH-089/07-infra-requirements.md`.
|
||||
- Тесты: `tests/test_labels.py`, `test_plane_sync_labels.py`, `test_auto_approve_brd.py`, `test_auto_deploy.py`, `test_auto_label_combinations.py`, `test_auto_labels_integration.py`, `test_auto_labels_invariants.py` (TC-01…TC-26). ADR: `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, global `docs/architecture/adr/adr-0018-auto-label-gates.md`.
|
||||
- **Per-repo serial gate: пакетный автономный режим (Этап 1, serial e2e)** (ORCH-088, `feat`): закрыт **логический** stale-анализ — ветка задачи N+1 срезалась на входе в анализ (`start_pipeline._create_gitea_branch`) от `main`, ещё не содержащего код предшественника N (физическое затирание уже закрыто ORCH-026). Новая задача репо не входит в `analysis` (не режет ветку, не запускает analyst), пока в репо есть незавершённая задача или репо заморожен. Аддитивно, под kill-switch, область репо, never-raise, restart-safe; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` — **без изменений**.
|
||||
- **Gate-в-claim** (`db.claim_next_job`): analyst-job (`jobs.agent='analyst'`) применимого репо не выбирается, если `EXISTS` **более ранняя** незавершённая задача репо (`t2.id < jobs.task_id`) ИЛИ активна строка `repo_freeze`. Фрагмент строится в leaf `src/serial_gate.py::build_claim_clause` (санитизация repo-токенов `^[A-Za-z0-9._-]+$`, **fail-OPEN** на любой ошибке построения — не заклинить очередь всех проектов, AC-8); только локальная БД (offline hot-path, NFR-2). Job'ы уже активной задачи проходят свободно. **FIFO-уточнение реализации (FR-2):** ADR-001 D1 фиксировал псевдо-SQL `t2.id != jobs.task_id`; при `!=` пакет одновременно созданных свежих задач (все в `analysis`) взаимно блокировался бы → дедлок всей serial-очереди (воспроизведено). `<` допускает ровно самую раннюю задачу и сериализует остальные за ней (строго по одной, FIFO по `jobs.id`), сохраняя AC-1 и не блокируя rework-analyst собственной задачи (R-7).
|
||||
- **Отложенный срез ветки (анти-stale-base, AC-6):** для применимого репо `start_pipeline` создаёт task-row + enqueue analyst, но **не** создаёт Gitea-ветку/docs; срез релоцирован в `launcher._spawn` (новый `_materialize_deferred_branch`, sync через `asyncio.run` в worker-потоке, R-4) на момент claim analyst-job, когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main, ORCH-071/073). `ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно (`_create_gitea_branch` 409 / `_create_initial_docs` 422 = no-op) → безопасно при реклейме/рестарте. Ожидающая задача = `queued` analyst-job без ветки; `tasks.branch` хранится как имя (R-5).
|
||||
- **Durable per-repo freeze (FR-5):** новая аддитивная append-only таблица `repo_freeze(id, repo, frozen_at, reason, work_item_id, cleared_at)` (`CREATE TABLE/INDEX IF NOT EXISTS` в `init_db`, идемпотентно, restart-safe). Post-deploy `DEGRADED` (`stage_engine.run_post_deploy_monitor`) → `serial_gate.set_repo_freeze` + Telegram-алерт «пакет заморожен»; gate закрыт безусловно (деградировавшая задача уже `done`, BR-7 ⇒ отдельный сигнал, независимый от `stage`) до **ручного** снятия — новый эндпоинт `POST /serial-gate/unfreeze?repo=<repo>` (`clear_repo_freeze`, идемпотентно, + Telegram-подтверждение; альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') …`). freeze в Python-слое (`is_repo_frozen`) → **fail-CLOSED** (безопасность прода, AC-9). Независимый тумблер `serial_gate_freeze_enabled`.
|
||||
- **Конфигурация (`src/config.py`):** `serial_gate_enabled` (kill-switch, `ORCH_SERIAL_GATE_ENABLED`, дефолт true → claim+start_pipeline 1:1 как сейчас при false), `serial_gate_repos` (CSV, `ORCH_SERIAL_GATE_REPOS`; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58; оператор может сузить), `serial_gate_freeze_enabled` (`ORCH_SERIAL_GATE_FREEZE_ENABLED`). Наблюдаемость — аддитивный блок `serial_gate` в `GET /queue` (per-repo `active_task`/`waiting`/`frozen`+reason+at); существующие ключи не меняются. **NFR-6:** freeze — пассивная остановка стартов, прод-контейнер не рестартится/не роняется. Cross-repo параллелизм сохранён (FR-3/AC-4); при выключенном флаге — нулевая регрессия (enduro не затронут, AC-7). ADR `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`, данные `08-data-requirements.md`, сквозной `adr-0017`. Документация: `docs/architecture/README.md` (раздел serial gate + `/queue` + таблица API + раздел БД), `CLAUDE.md`. Тесты: `tests/test_serial_gate.py` (TC-01/02/03/08/15/16/17/19/21), `tests/test_serial_gate_e2e.py` (TC-04/05/06), `tests/test_serial_gate_freeze.py` (TC-07/09/10/11/12/18/22), `tests/test_serial_gate_branch.py` (TC-13/14), `tests/test_queue_endpoint.py` (TC-20).
|
||||
- **CI-фикс: per-run путь логов из хардкода `/app/data/runs` в `settings.runs_dir`** (ORCH-087, `fix`): тест `tests/test_launcher.py::TestEffortStamp::test_spawn_stamps_resolved_effort` падал в CI (`PermissionError: [Errno 13] … '/app'`) — зелёный локально-в-контейнере (где `/app` есть), красный на CI-хосте (act_runner hostexecutor, юзер без доступа к `/app`). **Корень:** `launcher._spawn` хардкодил `output_path="/app/data/runs/{run_id}.log"` + `os.makedirs('/app/data/runs')`, а тест дёргал `_spawn`, не замокав путь → makedirs на недоступном `/app` бросал. **Фикс (корень, не только тест):** базовый каталог per-run логов вынесен в `Settings.runs_dir` (env `ORCH_RUNS_DIR`, дефолт `/app/data/runs` — прод-layout 1:1); новый хелпер `launcher._run_log_path(run_id)` = `<settings.runs_dir>/{run_id}.log` стал единым источником пути (использован в `_spawn` + три прежних inline-строки логов/алертов). Тест `monkeypatch`-ит `settings.runs_dir` на `tmp_path` → окружение-независим (подтверждено прогоном с принудительно недоступным `/app`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — без изменений. Документация: `README.md` (таблица env), `CHANGELOG.md`.
|
||||
- **Live-трекер: зачистка осиротевших карточек + эффорт в строке стадии + честное итоговое время** (ORCH-087, `fix`): в чат периодически попадали «замёрзшие» сироты — старая карточка с заголовком `📍 To Analyse` висела на задаче, реально дошедшей до `deploy` (скриншот ORCH-082). **Корень (G0/ADR-001):** указатель `tasks.tracker_message_id` — скаляр (знает лишь ПОСЛЕДНИЙ `message_id`), поэтому при рассинхроне bump-режима (доминанты: гонка двух `update_task_tracker` и `delete`-fail+`send`-ok) ссылка на прежнюю карточку терялась навсегда → сирота не удалялась и больше не обновлялась (рендер исправен — застывал именно потерянный mid). **Фикс (bump сохранён дефолтом — фича «карточка внизу» ORCH-042/067):**
|
||||
- **G1 — полный учёт mid:** аддитивная таблица-леджер `tracker_messages(task_id, message_id, created_at, deleted_at)` (`src/db.py`) + хелперы `add_tracker_message`/`get_open_tracker_messages`/`mark_tracker_message_deleted`. На каждом bump зачищаются ВСЕ незакрытые mid (`deleted_at IS NULL`), а не только скаляр: успех/«already gone» (`_DELETE_GONE_MARKERS`) → `deleted_at`; transient-`delete` → остаётся для ретрая; новый mid в леджер + `set_tracker_message_id` ТОЛЬКО при успешном `send` (R-3/BR-6). Остаточная гонка самозалечивается за один переход (лок не вводится). Скаляр `tracker_message_id` сохранён (BC). Known-limitation: Telegram 48ч (сироты старше неудаляемы).
|
||||
|
||||
35
CLAUDE.md
35
CLAUDE.md
@@ -7,7 +7,7 @@
|
||||
- Backend: FastAPI + uvicorn (Python 3.12)
|
||||
- БД: SQLite (`src/db.py`)
|
||||
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break).
|
||||
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`).
|
||||
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты.
|
||||
- Контейнеризация: Docker + Compose
|
||||
- CI/CD: Gitea Actions (`.gitea/workflows/`)
|
||||
- Деплой: docker compose на mva154
|
||||
@@ -78,6 +78,39 @@ created → analysis → architecture → development → review → testing →
|
||||
- Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification`
|
||||
(карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются.
|
||||
|
||||
## Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089)
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон
|
||||
(эпик ORCH-088): гейт BRD (`analysis`: ручной `Approved`) и гейт прод-деплоя
|
||||
(`deploy` Phase A: ручной `Confirm Deploy`, ORCH-059). ORCH-089 снимает **только эти
|
||||
два человеческих решения** — выборочно (лейбл Plane на задаче), декларативно,
|
||||
обратимо, **не трогая ни одной технической проверки**. Инвариант: авто-режим снимает
|
||||
лишь ожидание человеческого сигнала; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД
|
||||
— **не трогаются**. Аддитивно: leaf `src/labels.py` (never-raise) + две точечные врезки.
|
||||
- **`autoApprove`** → врезка в `stage_engine._handle_analysis_approved_flow` (ветка
|
||||
`files_ok`): `set_issue_approved` (индикация) + лог/Telegram/Plane-коммент +
|
||||
`advance_stage(..., finished_agent=None)` — **тот же путь, что человеческий Approved**
|
||||
(`approved-via-status` → `analysis → architecture` + `mark_brd_review_ended`).
|
||||
- **`autoDeploy`** → врезка в `stage_engine._handle_self_deploy_phase_a` после advance
|
||||
на `deploy` + `clear_state`: лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b`
|
||||
(маркер `INITIATED`, статус `Deploying`, finalizer). Пропускаются лишь
|
||||
индикативно-человеческие шаги. **BR-5 структурно:** Phase A достигается только после
|
||||
зелёных под-гейтов ребра `deploy-staging → deploy` (security → merge-gate →
|
||||
image-freshness → staging) → autoDeploy физически не деплоит сломанное.
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` (`None` при ошибке ≠ `[]`) +
|
||||
`get_project_labels` (`{normalized_name→uuid}`, TTL-кэш); сопоставление по
|
||||
нормализованному имени (`strip().casefold()`), неоднозначность → «нет лейбла».
|
||||
Источник истины — Plane API, не payload вебхука. Новый сеттер `set_issue_approved`.
|
||||
- **Флаги** (`config.py`): `auto_label_enabled` (kill-switch), `auto_approve_label`/
|
||||
`auto_deploy_label`, `auto_label_repos` (CSV; **пусто → self-hosting only**),
|
||||
`auto_label_states_ttl_s`. `applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label`
|
||||
(сеть) — только при `applies==True` → при выключенном флаге нулевой сетевой оверхед.
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность →
|
||||
«нет авто» → ручной гейт (never-raise). Прозрачность: лог + Telegram + Plane-коммент +
|
||||
live-карточка; блок `auto_labels` в `GET /queue`. **Инфра-предусловие:** создать лейблы
|
||||
`autoApprove`/`autoDeploy` в Plane-проекте ORCH (их отсутствие = ручной режим, fail-safe).
|
||||
Детали — `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
|
||||
`docs/architecture/adr/adr-0018-auto-label-gates.md`.
|
||||
|
||||
## Конвенции
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
||||
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||
|
||||
@@ -92,6 +92,84 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
|
||||
|
||||
Подробнее: [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md), детально — `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
|
||||
|
||||
### Per-repo serial gate: пакетный автономный режим (ORCH-088 — реализовано)
|
||||
Эпик «10–20 задач за ночь», Этап 1 (serial e2e). Закрывает **stale-анализ**: ветка задачи N+1
|
||||
срезалась на входе в анализ (`start_pipeline._create_gitea_branch`) от `main`, ещё не содержащего код
|
||||
предшественника N (физическое код-затирание уже закрыто ORCH-026; ORCH-088 — **логический** разрыв).
|
||||
Новая задача репо не входит в `analysis` (не режет ветку, не запускает analyst), пока в том же репо
|
||||
есть незавершённая задача (`stage != 'done'`) или репо заморожен. Аддитивно, под kill-switch, область
|
||||
репо, never-raise, restart-safe; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` — **без изменений**.
|
||||
- **Gate-в-claim** (`db.claim_next_job`) — analyst-job (`jobs.agent='analyst'`) применимого репо не
|
||||
выбирается, если `EXISTS` **более ранняя** незавершённая задача репо (`t2.id < jobs.task_id`) ИЛИ
|
||||
активна строка `repo_freeze`. По образцу `task_deps` `NOT EXISTS` (ORCH-026); только локальная БД
|
||||
(offline hot-path, NFR-2). Job'ы уже активной задачи проходят свободно. **FIFO-уточнение реализации
|
||||
(FR-2):** ADR-001 D1 фиксировал псевдо-SQL `t2.id != jobs.task_id`; при `!=` пакет одновременно
|
||||
созданных свежих задач (все в `analysis`) взаимно блокировался бы (каждая — «другая незавершённая»
|
||||
для остальных) ⇒ дедлок всей serial-очереди. `<` допускает ровно самую раннюю задачу и сериализует
|
||||
остальные за ней (строго по одной, FIFO по `jobs.id`), при этом по-прежнему не блокирует rework-analyst
|
||||
собственной задачи (R-7) и сохраняет AC-1.
|
||||
- **Отложенный срез ветки (анти-stale-base, AC-6):** для применимого репо `start_pipeline` создаёт
|
||||
task-row + enqueue analyst, но **не** создаёт Gitea-ветку/docs; срез релоцируется на момент claim
|
||||
analyst-job (launcher), когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main,
|
||||
ORCH-071/073). `ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно
|
||||
(`_create_gitea_branch` 409 = no-op).
|
||||
- **Durable per-repo freeze** (новая аддитивная таблица `repo_freeze`, `cleared_at IS NULL` = активен) —
|
||||
post-deploy `DEGRADED`/rollback (ORCH-021) → `set_repo_freeze` + Telegram-алерт; gate закрыт
|
||||
безусловно до **ручного** снятия (`POST /serial-gate/unfreeze`). Деградировавшая задача уже `done`
|
||||
(BR-7) ⇒ отдельный сигнал, независимый от `stage`.
|
||||
- **Согласование NFR-1:** hot-claim тотальный сбой построения gate-фрагмента → **fail-open** (не
|
||||
заклинить очередь всех проектов, AC-8); freeze в Python-слое (`is_repo_frozen`) → **fail-closed**
|
||||
(безопасность прода, AC-9).
|
||||
- Чистая логика — leaf `src/serial_gate.py` (never-raise). Флаги `serial_gate_enabled` (kill-switch),
|
||||
`serial_gate_repos` (CSV; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58),
|
||||
`serial_gate_freeze_enabled`. Наблюдаемость — аддитивный блок `serial_gate` в `GET /queue`
|
||||
(per-repo `active_task` / `waiting` / `frozen`). Cross-repo параллелизм сохранён (FR-3); при
|
||||
выключенном флаге — нулевая регрессия (enduro не затронут).
|
||||
|
||||
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md), детально —
|
||||
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
|
||||
`docs/work-items/ORCH-088/08-data-requirements.md`.
|
||||
|
||||
### Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089 — реализовано)
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон (эпик
|
||||
ORCH-088): гейт BRD (`analysis`: ждёт ручного `Approved`) и гейт прод-деплоя (`deploy`:
|
||||
Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снимает **только эти два
|
||||
человеческих решения** — выборочно (лейбл Plane на задаче), декларативно, обратимо, **не
|
||||
трогая ни одной технической проверки**. Аддитивно, по образцу условных под-гейтов
|
||||
(ORCH-035/043/058/059/088): leaf `src/labels.py` (never-raise) + точечные врезки + флаги;
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схема БД — **не трогаются**.
|
||||
- **`autoApprove`** → врезка в `stage_engine._handle_analysis_approved_flow` (ветка
|
||||
`files_ok`) после `In Review`+коммента: `set_issue_approved` (индикация) +
|
||||
лог/Telegram/Plane-коммент + `advance_stage(..., finished_agent=None)` — **тот же путь, что
|
||||
человеческий Approved** (`approved-via-status` → `analysis → architecture` +
|
||||
`mark_brd_review_ended`). Без дублирования переходной логики.
|
||||
- **`autoDeploy`** → врезка в `stage_engine._handle_self_deploy_phase_a` сразу после advance
|
||||
на `deploy` + `clear_state`: лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b(...)`
|
||||
(idempotency-маркер `INITIATED`, статус `Deploying`, finalizer). Пропускаются лишь
|
||||
индикативно-человеческие шаги (`Awaiting Deploy` + «ask-human»). **BR-5 структурно:** Phase A
|
||||
достигается только после зелёных под-гейтов ребра `deploy-staging → deploy` (security →
|
||||
merge-gate → image-freshness → staging) → autoDeploy физически не деплоит сломанное.
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` (поле `labels` issue, `None` при
|
||||
ошибке ≠ `[]`) + `get_project_labels` (`{normalized_name→uuid}`, TTL-кэш по образцу
|
||||
`get_project_states`); сопоставление по нормализованному имени (`strip().casefold()`),
|
||||
неоднозначность → «нет лейбла». Источник истины — Plane API, не payload вебхука. Новый
|
||||
сеттер `set_issue_approved` (ключ `approved` уже в `_DEFAULT_STATES`).
|
||||
- **Флаги** (`config.py`): `auto_label_enabled` (kill-switch), `auto_approve_label`/
|
||||
`auto_deploy_label`, `auto_label_repos` (CSV; **пусто → self-hosting only**),
|
||||
`auto_label_states_ttl_s`. `applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label`
|
||||
(сеть) — только при `applies==True` → при выключенном флаге нулевой сетевой оверхед,
|
||||
нулевая регрессия для enduro.
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность →
|
||||
«нет авто» → ручной гейт (never-raise). **Идемпотентность:** autoApprove — advance один раз
|
||||
(поздний Approved/F-2 видят `architecture`); autoDeploy — маркер `INITIATED`. **Прозрачность
|
||||
(AC-7):** лог + Telegram + Plane-коммент + live-карточка; блок `auto_labels` в `GET /queue`.
|
||||
- **Инфра-предусловие:** создать лейблы `autoApprove`/`autoDeploy` в Plane-проекте ORCH
|
||||
(labels API); их отсутствие = `has_label` False = ручной режим (fail-safe).
|
||||
|
||||
Подробнее: [adr-0018](adr/adr-0018-auto-label-gates.md), детально —
|
||||
`docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
|
||||
`docs/work-items/ORCH-089/07-infra-requirements.md`.
|
||||
|
||||
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
|
||||
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
|
||||
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
|
||||
@@ -561,6 +639,7 @@ Monitoring after Deploy → Done
|
||||
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||
- `jobs` — очередь задач (ORCH-1); колонка `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`)
|
||||
|
||||
## Изоляция (git worktree, ORCH-2)
|
||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||
@@ -570,7 +649,8 @@ 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) + последние 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) + последние jobs |
|
||||
| POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=<repo>`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` |
|
||||
| POST | `/webhook/plane` | Plane webhook |
|
||||
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||
|
||||
@@ -588,3 +668,4 @@ Monitoring after Deploy → Done
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).*
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).*
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-068 (D1 терминал-гард по группе `_is_terminal_state` + `get_project_state_groups` в src/plane_sync.py; D2 сравнение стадии до/после `_dispatch` + дедуп-словарь в src/reconciler.py; TTL-запись `_STATES_CACHE` + флаг `plane_states_ttl_s` в src/config.py; счётчики `skipped_terminal_total`/`deduped_total` в `/queue`; обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`get_project_state_groups`/`_STATES_CACHE`).*
|
||||
*Актуально на 2026-06-09. Статус доработки: ORCH-088 (per-repo serial gate, Этап 1 serial e2e, adr-0017, `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`) — реализовано в ветке feature/ORCH-088 (leaf src/serial_gate.py never-raise: gate-фрагмент в src/db.py `claim_next_job` fail-OPEN c FIFO-условием `t2.id < jobs.task_id` + freeze `repo_freeze.cleared_at IS NULL`, freeze-решения fail-CLOSED; отложенный срез ветки src/webhooks/plane.py `start_pipeline` → src/agents/launcher.py `_materialize_deferred_branch` (sync `asyncio.run` в worker-потоке) при claim analyst-job; durable freeze таблица `repo_freeze` (idempotent миграция в init_db) + `set_repo_freeze` в src/stage_engine.py DEGRADED-ветке `run_post_deploy_monitor` + ручное снятие `POST /serial-gate/unfreeze` в src/main.py; флаги `serial_gate_enabled`/`serial_gate_repos`/`serial_gate_freeze_enabled` в src/config.py; блок `serial_gate` в `GET /queue`; `STAGE_TRANSITIONS`/`QG_CHECKS` НЕ трогаются; обновлять также при изменении этих мест).*
|
||||
|
||||
@@ -22,11 +22,12 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0014 | SHA-в-main — единственный критерий merge-verify + регресс-гард | accepted | 2026-06-08 | ORCH-073 |
|
||||
| adr-0015 | Зависимости задач (B ждёт A) + сериализация merge внутри репо | accepted | 2026-06-08 | ORCH-026 |
|
||||
| adr-0016 | ensure_open_pr — гарантированный код-PR перед merge-verify | accepted | 2026-06-09 | ORCH-082 |
|
||||
| adr-0017 | Per-repo serial gate (пакетный автономный режим, serial e2e) | proposed | 2026-06-09 | ORCH-088 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0016`).
|
||||
> свободный номер (текущий максимум — `0017`).
|
||||
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
|
||||
> adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082).
|
||||
|
||||
|
||||
59
docs/architecture/adr/adr-0017-serial-gate.md
Normal file
59
docs/architecture/adr/adr-0017-serial-gate.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# adr-0017: Per-repo serial gate (пакетный автономный режим, serial e2e)
|
||||
|
||||
Статус: **proposed** · Дата: 2026-06-09 · Источник: **ORCH-088** (Этап 1)
|
||||
Детально: `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`.
|
||||
|
||||
## Контекст
|
||||
Цель эпика ORCH-088 — масштаб автономности: накидать вечером 10–20 задач и получить к утру пакет,
|
||||
последовательно проведённый через весь конвейер (analysis → … → deploy → done). Корневая проблема —
|
||||
**stale-анализ**: ветка задачи N+1 срезается на входе в анализ (`start_pipeline._create_gitea_branch`)
|
||||
от `main`, ещё не содержащего код предшественника N. Физическое код-затирание уже закрыто (ORCH-026
|
||||
auto_rebase + merge-lease); остаётся **логический** разрыв. Plane API v1 не имеет bulk/relations ⇒
|
||||
очередь/зависимости хранятся у оркестратора (gate по локальной БД).
|
||||
|
||||
## Решение
|
||||
**Per-repo serial gate** — новая задача репо не входит в `analysis` (не режет ветку, не запускает
|
||||
analyst), пока в том же репо есть незавершённая задача (`stage != 'done'`) или репо заморожен.
|
||||
Три механизма, аддитивно, под kill-switch, с областью репо, never-raise, restart-safe:
|
||||
|
||||
1. **Gate-в-claim** (`db.claim_next_job`) — analyst-job (`jobs.agent='analyst'`) применимого репо не
|
||||
выбирается, если `EXISTS` другая незавершённая задача репо ИЛИ активна строка `repo_freeze`. По
|
||||
образцу `task_deps` `NOT EXISTS` (ORCH-026); только локальная БД (offline hot-path). Job'ы уже
|
||||
активной задачи проходят свободно; rework-analyst не блокирует себя (`t2.id != jobs.task_id`).
|
||||
2. **Отложенный срез ветки** — для применимого репо `start_pipeline` создаёт task-row + enqueue
|
||||
analyst, но **не** создаёт Gitea-ветку/docs; срез релоцируется на момент claim analyst-job
|
||||
(launcher), когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main, ORCH-071/073).
|
||||
`ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно (409 = no-op).
|
||||
3. **Durable per-repo freeze** (`repo_freeze`) — post-deploy `DEGRADED`/rollback (ORCH-021) →
|
||||
`set_repo_freeze` + Telegram-алерт; gate закрыт безусловно до **ручного** снятия
|
||||
(`POST /serial-gate/unfreeze`). Деградировавшая задача уже `done` (BR-7) ⇒ нужен отдельный сигнал.
|
||||
|
||||
Чистая логика — leaf `src/serial_gate.py` (never-raise). Флаги `serial_gate_enabled` (kill-switch),
|
||||
`serial_gate_repos` (CSV; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58),
|
||||
`serial_gate_freeze_enabled`. Наблюдаемость — блок `serial_gate` в `GET /queue`.
|
||||
|
||||
## Альтернативы
|
||||
- **Гейт в `start_pipeline` + re-trigger при `done`** — больше состояния/путей, риск зависших задач;
|
||||
relocation на claim переиспользует restart-safe `jobs`-очередь.
|
||||
- **Freeze как колонка `tasks`** — неверная семантика (freeze per-repo, задача уже `done`).
|
||||
- **Self-hosting-only область** — лишает enduro анти-stale-base (FR-3).
|
||||
- **Отдельная таблица очереди ожидания** — избыточно; `jobs(queued)`+gate достаточно.
|
||||
- **Снятие freeze Plane-жестом** — перегрузка статусов (анти-паттерн ORCH-059).
|
||||
|
||||
## Последствия
|
||||
- **+** AC-6 закрыт структурно; AC-2/AC-3 «бесплатны» (ожидание = `queued` job без ветки);
|
||||
переиспользование проверенных паттернов; cross-repo параллелизм сохранён; `STAGE_TRANSITIONS` /
|
||||
`QG_CHECKS` / `check_*` / merge-gate / merge-verify / image-freshness / post-deploy / deploy-хук /
|
||||
`max_concurrency` — **без изменений**.
|
||||
- **NFR-1:** hot-claim тотальный сбой → **fail-open** (не заклинить очередь всех проектов); freeze в
|
||||
Python-слое → **fail-closed** (безопасность прода).
|
||||
- **−** Срез ветки/docs мигрируют из async в sync-путь launcher (обёртка); Blocked-задача держит пакет
|
||||
(Этап 1, осознанно); freeze снимается только вручную.
|
||||
- Откат: `serial_gate_enabled=False` ⇒ claim/старт 1:1 как до ORCH-088; таблица `repo_freeze` инертна.
|
||||
- **Вне скопа** (Этап 1): merge-очередь FIFO, pre-merge rebase как отдельная фича, фазы A/B/C,
|
||||
любой параллелизм задач внутри одного репо, зависимость от ORCH-83.
|
||||
|
||||
## Связи
|
||||
- Переиспользует: adr-0002 (очередь ORCH-1), adr-0015 (claim-gate/auto_rebase/merge-lease ORCH-026),
|
||||
adr-0010 (post-deploy monitor — источник DEGRADED), adr-0013/0014 (merge-verify ⇒ `done`⇔SHA-в-main).
|
||||
- Новая аддитивная таблица `repo_freeze` (`docs/work-items/ORCH-088/08-data-requirements.md`).
|
||||
59
docs/architecture/adr/adr-0018-auto-label-gates.md
Normal file
59
docs/architecture/adr/adr-0018-auto-label-gates.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# ADR-0018: Авто-режим по лейблам — autoApprove / autoDeploy (ORCH-089)
|
||||
|
||||
## Статус
|
||||
Accepted (реализация — ORCH-089)
|
||||
|
||||
## Контекст
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон
|
||||
(эпик ORCH-088, «10–20 задач за ночь»):
|
||||
1. **BRD** (`analysis`): ждёт ручного Plane-статуса `Approved` → advance на `architecture`.
|
||||
2. **Прод-деплой** (`deploy`): Phase A ставит `Awaiting Deploy` и ждёт ручного
|
||||
`Confirm Deploy` (ORCH-059) → Phase B (`initiate_deploy`).
|
||||
|
||||
Для доверенных задач оба клика избыточны. Нужно снять **только эти два человеческих
|
||||
решения**, выборочно/декларативно (лейбл Plane на задаче), не ослабляя ни одной
|
||||
технической проверки.
|
||||
|
||||
## Решение
|
||||
Аддитивно, по образцу условных под-гейтов (ORCH-035/043/058/059/088): leaf-модуль чистой
|
||||
логики `src/labels.py` (never-raise) + точечные врезки + флаги. `STAGE_TRANSITIONS`, реестр
|
||||
`QG_CHECKS`, все `check_*`, схема БД — **не трогаются**.
|
||||
|
||||
- **`autoApprove`** (лейбл задачи) → в `_handle_analysis_approved_flow` (ветка `files_ok`)
|
||||
после `In Review`+коммента: `set_issue_approved` (индикация) + лог/Telegram/Plane-коммент +
|
||||
`advance_stage(..., finished_agent=None)` — тот же путь, что человеческий Approved
|
||||
(`approved-via-status` → `analysis → architecture` + `mark_brd_review_ended`). Без
|
||||
дублирования переходной логики.
|
||||
- **`autoDeploy`** (лейбл задачи) → в `_handle_self_deploy_phase_a` сразу после advance на
|
||||
`deploy` + `clear_state`: лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b(...)`
|
||||
(idempotency-маркер `INITIATED`, `Deploying`, finalizer). Пропускаются лишь
|
||||
индикативно-человеческие шаги (`Awaiting Deploy` + «ask-human»).
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` + `get_project_labels` (TTL-кэш,
|
||||
образец `get_project_states`); сопоставление по нормализованному имени; источник истины —
|
||||
Plane API (не payload). Новый сеттер `set_issue_approved` (ключ `approved` уже в states).
|
||||
- **Флаги:** `auto_label_enabled` (kill-switch), `auto_approve_label`/`auto_deploy_label`
|
||||
(имена), `auto_label_repos` (CSV; **пусто → self-hosting only**), `auto_label_states_ttl_s`.
|
||||
`applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label` (сеть) — только если
|
||||
`applies==True` → при выключенном флаге нулевой сетевой оверхед.
|
||||
|
||||
## Критические инварианты
|
||||
- **Авто-режим снимает ТОЛЬКО человеческое решение**, не ослабляя ни один тех-гейт
|
||||
(CI / staging / security / merge-gate / image-freshness / merge-verify / regression-guard /
|
||||
post-deploy). autoDeploy живёт в точке, где все под-гейты ребра `deploy-staging → deploy`
|
||||
уже зелёные → структурно «никогда не деплоит сломанное».
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность имени →
|
||||
«нет авто» → ручной гейт (согласовано с fail-closed-практикой ORCH-059). never-raise.
|
||||
- **Нулевая регрессия:** без лейблов / `auto_label_enabled=False` / репо вне scope →
|
||||
поведение 1:1 как до ORCH-089 (enduro не затронут).
|
||||
- **Идемпотентность:** autoApprove — advance применяется один раз (поздний Approved/F-2
|
||||
видят уже `architecture`); autoDeploy — маркер `INITIATED`.
|
||||
|
||||
## Последствия
|
||||
**+** минимальная поверхность, единый источник истины перехода, декларативно/обратимо,
|
||||
независимые лейблы, безопасный дефолт. **−** Approved-статус транзиентен (durable-аудит —
|
||||
лог/Telegram/коммент); 1–2 GET к Plane на гейт применимого репо (TTL-кэш карты лейблов);
|
||||
требуется однократно создать лейблы в Plane-проекте ORCH (инфра-предусловие; их отсутствие =
|
||||
fail-safe ручной режим).
|
||||
|
||||
Детально: `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
|
||||
`07-infra-requirements.md`, `10-tech-risks.md`.
|
||||
12
docs/work-items/ORCH-087/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-087/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-087
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
7
docs/work-items/ORCH-088/00-business-request.md
Normal file
7
docs/work-items/ORCH-088/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-88 [ЭПИК]: пакетный автономный режим (10-20 задач за ночь) — последовательно → потом параллельно
|
||||
|
||||
Work Item ID: ORCH-088
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
145
docs/work-items/ORCH-088/01-brd.md
Normal file
145
docs/work-items/ORCH-088/01-brd.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 01 — BRD: ORCH-088 — Пакетный автономный режим (Этап 1: serial e2e)
|
||||
|
||||
Work Item: **ORCH-088**
|
||||
Repo: **orchestrator** (self-hosting)
|
||||
Стадия: analysis
|
||||
Заказчик: Слава
|
||||
Тип: ЭПИК — Этап 1 (минимальный, без параллелизма)
|
||||
|
||||
> ⚠️ **Скоп зафиксирован Владельцем 09.06.** Реализуется ТОЛЬКО serial e2e (FR-1…FR-5).
|
||||
> Фазовый режим A/B/C, merge-очередь FIFO, pre-merge rebase и зависимость от ORCH-83 —
|
||||
> **ОТМЕНЕНЫ, не реализовывать.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-контекст и проблема
|
||||
|
||||
### 1.1. Цель эпика
|
||||
Дать оркестратору **масштаб автономности**: накидать вечером 10–20 задач и получить к утру
|
||||
последовательно проведённый через весь конвейер (analysis → … → deploy → done) пакет — без
|
||||
ручного запуска каждой задачи и без взаимного повреждения веток.
|
||||
|
||||
### 1.2. Корневая проблема — «stale-анализ» (логический, а не код-затирание)
|
||||
Конвейер создаёт ветку задачи от `main`. Если задача **N+1** входит в анализ, пока задача **N**
|
||||
ещё **не влита в `main`**, то ветка N+1 срезается от **устаревшего** `main` (без кода N). Результат:
|
||||
- семантически устаревшая база разработки;
|
||||
- риск потери/переоткрытия уже сделанного в N (накопительные потери прецедента — постмортем
|
||||
фантомного merge, см. CLAUDE.md / ORCH-071);
|
||||
- ручной разбор конфликтов утром вместо готового пакета.
|
||||
|
||||
Физическое **код-затирание** при параллельном merge уже закрыто (ORCH-026 auto_rebase + merge-lease).
|
||||
ORCH-088 закрывает **логический** разрыв: гарантирует, что каждая следующая задача стартует от
|
||||
`main`, **уже содержащего все предыдущие завершённые задачи репо**.
|
||||
|
||||
### 1.3. Почему сериализация именно «от АНАЛИЗА», а не «от merge»
|
||||
Ветка срезается в самом начале — на входе в анализ (`start_pipeline` создаёт ветку в Gitea, далее
|
||||
worktree). Если допустить параллельный анализ N и N+1, ветка N+1 уже срезана от старого `main` —
|
||||
поздняя сериализация на merge проблему не лечит. Поэтому gate ставится на **входе новой задачи в
|
||||
анализ**: новая задача не начинает анализ (и не режет ветку), пока в репо есть незавершённая задача.
|
||||
|
||||
### 1.4. Установленные факты (проверено, не изобретать)
|
||||
- **Plane API v1:** bulk-операций НЕТ; issue-relation НЕТ → зависимости/очередь оркестратор хранит
|
||||
**у себя** (gate в планировщике/claim по локальной БД), не в Plane.
|
||||
- **Уже есть (переиспользовать):** `max_concurrency=1`; ORCH-026 auto_rebase_onto_main +
|
||||
force-with-lease + merge-lease; персистентная очередь ORCH-1 (таблица `jobs`, atomic claim,
|
||||
restart-safe); ORCH-021 post-deploy monitor (для self — всегда `ALERT_ONLY`, db-стадия `done`
|
||||
достигается ДО окна мониторинга — ORCH-071/066).
|
||||
|
||||
### 1.5. Решения Владельца (09.06) — приняты как требования
|
||||
| # | Решение |
|
||||
|---|---------|
|
||||
| D-1 | Serial e2e подтверждён. BRD появляются **по одному** — осознанный размен: надёжность > батч-просмотр BRD. |
|
||||
| D-2 | Сигнал «задача завершена» = **успешный прод-деплой** (`stage = done` после прод-деплоя). НЕ merge, НЕ staging. |
|
||||
| D-3 | Мониторинг (~15 мин) **НЕ ждём**: gate N+1 открывается по `stage = done`, не по завершению окна мониторинга. |
|
||||
| D-4 | Auto-rollback прода во время мониторинга → **заморозить gate + алерт**; следующая НЕ стартует до ручного снятия. |
|
||||
| D-5 | Зависимость ORCH-088 ← ORCH-83 **убрана** — запускается независимо. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Объём (scope)
|
||||
|
||||
### 2.1. В объёме (Этап 1)
|
||||
- **FR-1 — Serial gate (per-repo):** новая задача не входит в `analysis` (не режет ветку, не
|
||||
запускает analyst), пока в том же репо есть незавершённая задача (`stage < done`).
|
||||
- **FR-2 — Очередь e2e:** накиданные задачи становятся в очередь и обрабатываются **строго по одной**
|
||||
end-to-end (от анализа до прод-деплоя).
|
||||
- **FR-3 — Per-repo изоляция:** сериализация действует **внутри одного репо**; разные репо
|
||||
(`orchestrator`, `enduro-trails`) идут **параллельно** (независимые `main`).
|
||||
- **FR-4 — Restart-safe:** активная задача и состояние gate определяются по **БД** (не in-memory) —
|
||||
переживают рестарт оркестратора.
|
||||
- **FR-5 — Rollback-freeze:** auto-rollback / деградация прода → gate репо **заморожен** + Telegram-
|
||||
алерт; следующая задача не стартует до **ручного** снятия заморозки.
|
||||
|
||||
### 2.2. Вне объёма (явно, не делать)
|
||||
- Merge-очередь FIFO; pre-merge rebase как отдельная фича; фазовый режим A/B/C; любая координация
|
||||
**параллелизма** задач внутри одного репо.
|
||||
- Изменение `STAGE_TRANSITIONS`, реестра `QG_CHECKS`, новых стадий конвейера.
|
||||
- Зависимость от ORCH-83.
|
||||
|
||||
---
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
- **Владелец/оператор (Слава):** накидывает пакет вечером, разбирает заморозку при сбое, читает
|
||||
алерты, снимает freeze вручную.
|
||||
- **Self-hosting прод (`orchestrator`):** обслуживает enduro-trails из того же инстанса — нельзя
|
||||
ронять/блокировать конвейер enduro (FR-3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Бизнес-требования (BR)
|
||||
|
||||
| ID | Требование | Связь |
|
||||
|----|------------|-------|
|
||||
| BR-1 | Пока в репо есть задача со `stage < done`, любая **другая** задача того же репо не начинает анализ — ждёт в очереди. | FR-1, AC-1 |
|
||||
| BR-2 | Как только активная задача достигла `stage = done` (после прод-деплоя), следующая задача того же репо **автоматически** стартует анализ. | FR-1/FR-2, AC-2, D-2 |
|
||||
| BR-3 | Ветка новой задачи срезается от `main`, **уже содержащего все ранее завершённые задачи репо** — нет stale-base. Branch не создаётся раньше, чем предшественник завершён. | FR-1, AC-6, §1.2 |
|
||||
| BR-4 | Сериализация — строго per-repo; задачи разных репо идут параллельно, gate одного репо не влияет на другой. | FR-3, AC-4 |
|
||||
| BR-5 | Активная задача и факт заморозки определяются из БД; после рестарта оркестратора gate ведёт себя идентично (не «забывает» активную задачу и не «теряет» freeze). | FR-4, AC-3 |
|
||||
| BR-6 | Auto-rollback/деградация прода (post-deploy) → per-repo freeze + Telegram-алерт; следующая задача не стартует до ручного снятия freeze. | FR-5, AC-5, D-4 |
|
||||
| BR-7 | Мониторинг прода (~15 мин) gate **не ждёт** — открытие gate привязано к `stage = done`. (Freeze BR-6 — отдельный, независимый от `stage` сигнал, т.к. к моменту деградации задача уже `done`.) | D-3, AC-5 |
|
||||
| BR-8 | Поведение управляется kill-switch'ом и областью репо (как ORCH-35/43/58): выключение флага → строго прежнее поведение (нулевая регрессия для enduro). | NFR |
|
||||
| BR-9 | Состояние gate наблюдаемо в `GET /queue` (активная задача репо, очередь ожидающих, статус freeze). | NFR |
|
||||
|
||||
---
|
||||
|
||||
## 5. Нефункциональные требования (NFR)
|
||||
|
||||
| ID | Требование |
|
||||
|----|------------|
|
||||
| NFR-1 | **never-raise:** любая ошибка логики gate не роняет claim/конвейер. Поведение при ошибке БД — **fail-open** для claim (транзиентный сбой не должен заклинить очередь ВСЕХ проектов), **fail-closed** для freeze (сомнение в безопасности прода → не стартовать). |
|
||||
| NFR-2 | **Offline-устойчивость:** проверка gate в горячем цикле claim не должна ходить в сеть (Plane/Gitea) — иначе встанет очередь всех проектов. Источник истины — локальная БД. |
|
||||
| NFR-3 | **Restart-safe:** никакого in-memory состояния; freeze и активная задача — в БД. |
|
||||
| NFR-4 | **Нулевая регрессия:** при выключенном флаге запрос claim и путь старта идентичны текущим; enduro не затрагивается. |
|
||||
| NFR-5 | **Инварианты неизменны:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды deploy-хука, merge-gate, схема post-deploy — не меняются (допустима только аддитивная, идемпотентная миграция БД). |
|
||||
| NFR-6 | **Self-hosting безопасность:** механизм не рестартит/не роняет прод-контейнер; freeze — пассивная остановка стартов, не действие над прод. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
- `max_concurrency = 1` остаётся (Этап 1 без параллелизма); gate не зависит от значения, но не
|
||||
ослабляет его.
|
||||
- «Завершена» = `tasks.stage = 'done'`. Для self-hosting `done` достигается merge-verify + прод-деплой
|
||||
(ORCH-071/036); пост-деплойное окно мониторинга идёт **после** `done` и gate его не ждёт (BR-7).
|
||||
- Задача в статусе **Blocked / Needs Input** имеет `stage < done` и, следовательно, **держит gate
|
||||
закрытым** — это сознательное поведение (Этап 1): пока задача не доведена до прод или не закрыта
|
||||
оператором, пакет не движется. (Поведение зафиксировать в AC; альтернатива — вне скопа.)
|
||||
- Снятие freeze (BR-6) — **ручное** (оператор), автоматического разбора деградации нет.
|
||||
|
||||
---
|
||||
|
||||
## 7. Критерии успеха (резюме; детали — 03-acceptance-criteria.md)
|
||||
- AC-1 активная задача (`stage<done`) → новая не стартует анализ.
|
||||
- AC-2 активная достигла `done` → следующая стартует автоматически.
|
||||
- AC-3 gate переживает рестарт (состояние в БД).
|
||||
- AC-4 разные репо идут параллельно.
|
||||
- AC-5 auto-rollback → freeze + алерт, следующая не стартует до ручного снятия.
|
||||
- AC-6 каждая ветка срезана от `main` со всеми предыдущими завершёнными задачами репо (нет stale-base).
|
||||
|
||||
---
|
||||
|
||||
## 8. Риски (детали — 10-tech-risks.md, заполняет архитектор)
|
||||
- R-1: stale-base сохраняется, если ветка режется на входе (`_create_gitea_branch` в `start_pipeline`)
|
||||
до завершения предшественника — gate обязан отсрочить **создание ветки**, а не только claim.
|
||||
- R-2: gate, ошибочно fail-closed на транзиентной ошибке БД, заклинит очередь всех проектов.
|
||||
- R-3: «вечный freeze» / залипшая активная задача в Blocked останавливает пакет — нужна наблюдаемость
|
||||
и ручное снятие.
|
||||
210
docs/work-items/ORCH-088/02-trz.md
Normal file
210
docs/work-items/ORCH-088/02-trz.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 02 — ТЗ (TRZ): ORCH-088 — Serial gate (Этап 1: пакетный автономный режим, serial e2e)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
> Документ описывает **что** должно измениться и **где** (модули/контракты/артефакты). **Как**
|
||||
> (конкретная схема реализации, выбор «таблица vs sentinel», точки врезки) — решает архитектор в
|
||||
> `06-adr/`. ТЗ фиксирует требования и границы, не предлагает архитектурное решение.
|
||||
|
||||
> ⚠️ Скоп — только FR-1…FR-5 (serial e2e). Merge-очередь / pre-merge rebase / фазы A/B/C / ORCH-83 —
|
||||
> вне скопа.
|
||||
|
||||
---
|
||||
|
||||
## 1. Сводка изменения
|
||||
Ввести **per-repo serial gate**: новая задача репо не входит в стадию `analysis` (не режет ветку, не
|
||||
запускает analyst-агент), пока в том же репо есть незавершённая задача (`stage != 'done'`). Открытие
|
||||
gate — по достижении предшественником `stage = 'done'` (после прод-деплоя). Дополнительно — **per-repo
|
||||
freeze** при деградации/rollback прода (post-deploy), снимаемый вручную. Всё — аддитивно, под
|
||||
kill-switch, с областью репо, never-raise, restart-safe. Машина стадий и реестр QG **не меняются**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче | Характер изменения |
|
||||
|--------|---------------|--------------------|
|
||||
| `src/db.py` | `claim_next_job` (горячий claim), схема `tasks`/`jobs`, helper'ы выборки активной задачи репо; (возможно) аддитивная таблица/колонка для freeze | gate-условие в claim + новые read-only helper'ы + аддитивная миграция (идемпотентная, `_ensure_column`/`CREATE TABLE IF NOT EXISTS`) |
|
||||
| `src/queue_worker.py` | вызывает `claim_next_job` в `_drain_once` | без изменения контракта; gate работает внутри claim |
|
||||
| `src/webhooks/plane.py` | `start_pipeline` / `handle_status_start` / `_create_gitea_branch` | **отсрочка создания ветки** до момента, когда репо свободен (ключевое для AC-6); постановка задачи в очередь ожидания вместо немедленного среза ветки |
|
||||
| `src/git_worktree.py` | `ensure_worktree` — срез ветки от `origin/main` | гарантия: для новой задачи база = свежий `origin/main` после `git fetch` (см. §6) |
|
||||
| `src/agents/launcher.py` | `_spawn` — ленивое создание worktree на claim | согласование с отсрочкой среза ветки (не материализовать stale-ветку) |
|
||||
| `src/stage_engine.py` | `run_post_deploy_monitor` / блок `next_stage == "done"` | при вердикте деградации/rollback — выставить per-repo freeze (FR-5) |
|
||||
| `src/post_deploy.py` | `decide_action` / реакция | сигнал для freeze (`ALERT_ONLY` self / `ROLLBACK*` non-self) → выставление freeze |
|
||||
| `src/config.py` | флаги фичи | новые: `serial_gate_enabled`, `serial_gate_repos` (CSV), при необходимости — флаги freeze |
|
||||
| `src/main.py` | `GET /queue` | новый read-only блок наблюдаемости `serial_gate` |
|
||||
| `src/notifications.py` / `src/plane_sync.py` | алерты freeze | переиспользовать `send_telegram` / `set_issue_blocked` / `notify_*` (never-raise) |
|
||||
|
||||
> Чистую логику gate/freeze желательно вынести в **leaf-модуль** (например `src/serial_gate.py`,
|
||||
> never-raise, по образцу `src/task_deps.py` / `src/post_deploy.py`) — окончательно решает архитектор.
|
||||
|
||||
---
|
||||
|
||||
## 3. Функциональные изменения (требования к поведению)
|
||||
|
||||
### 3.1. FR-1 — Serial gate на входе в анализ
|
||||
- **Условие закрытия gate (per-repo):** для репо `R` gate **закрыт**, если существует задача `A` репо
|
||||
`R` со `stage != 'done'` (любая стадия `created…deploy`), **отличная** от рассматриваемой новой
|
||||
задачи `B`.
|
||||
- **Что блокируется при закрытом gate:** запуск analyst-агента новой задачи `B` **и** создание её
|
||||
ветки (Gitea-ветка + worktree). Branch у `B` не должен быть срезан, пока gate закрыт (иначе stale-base,
|
||||
AC-6).
|
||||
- **Где гейтить:** в горячем пути выбора работы — `db.claim_next_job` (по образцу `task_deps` NOT EXISTS
|
||||
gate), читая ТОЛЬКО локальную БД (NFR-2). Дополнительно — на входе `start_pipeline`, чтобы **не резать
|
||||
ветку** до открытия gate (см. §3.3).
|
||||
- **Применимость:** gate работает только для analyst-job новой задачи (вход в анализ). Job'ы уже
|
||||
активной задачи (architect/developer/…/deployer) проходят свободно — иначе единственная активная
|
||||
задача не сможет двигаться по конвейеру.
|
||||
|
||||
### 3.2. FR-2 — Очередь e2e
|
||||
- Накиданные задачи репо встают в очередь; обрабатывается строго одна end-to-end. Реализуется
|
||||
естественно: gate держит остальных, активная идёт по стадиям до `done`, затем gate открывается и
|
||||
выбирается следующая (FIFO по существующему порядку очереди `jobs.id`).
|
||||
|
||||
### 3.3. FR-1/AC-6 — Отсрочка среза ветки (анти-stale-base)
|
||||
- **Проблема (проверено):** ветка создаётся в Gitea в `start_pipeline._create_gitea_branch` от `main`
|
||||
в момент перевода issue в «To Analyse» (T0) — **до** того, как предшественник влит. `ensure_worktree`
|
||||
затем **присоединяет уже существующую** Gitea-ветку (а не режет свежую от `origin/main`), т.е. свежий
|
||||
`git fetch` не спасает — база остаётся stale.
|
||||
- **Требование:** создание ветки (Gitea-ветка и/или worktree) для новой задачи должно происходить
|
||||
**после** того, как gate открылся (предшественник `done`), чтобы базой был `origin/main`, уже
|
||||
содержащий код предшественника. Конкретный механизм отсрочки (отложить `_create_gitea_branch`;
|
||||
материализовать ветку лениво при claim'е analyst-job из свежего `origin/main`; и т.п.) — выбирает
|
||||
архитектор. Инвариант результата: **ветка `B` имеет в предках merge-commit/код всех ранее
|
||||
завершённых задач репо** (проверяемо `git merge-base --is-ancestor`).
|
||||
- Если архитектура решит резать ветку при claim'е analyst-job (а не в `start_pipeline`), это
|
||||
автоматически даёт AC-6 (claim происходит только при открытом gate).
|
||||
|
||||
### 3.4. FR-3 — Per-repo
|
||||
- Все выборки gate фильтруются по `tasks.repo` (и `jobs.repo`). Состояние gate/freeze репо `R` не
|
||||
влияет на claim/старт задач другого репо. Cross-repo параллелизм сохранён.
|
||||
|
||||
### 3.5. FR-4 — Restart-safe
|
||||
- «Активная задача репо» вычисляется запросом к БД (`tasks` по `repo` + `stage != 'done'`), не из
|
||||
in-memory. Freeze хранится в БД (аддитивная таблица/колонка). После рестарта поведение идентично.
|
||||
|
||||
### 3.6. FR-5 — Rollback-freeze
|
||||
- При вердикте post-deploy `DEGRADED` (для self — реакция `ALERT_ONLY`; для non-self с
|
||||
`post_deploy_auto_rollback` — `ROLLBACK`) для репо выставляется **durable freeze** (в БД).
|
||||
- При активном freeze репо gate **закрыт безусловно**, независимо от наличия задач `stage<done`
|
||||
(важно: деградировавшая задача к этому моменту уже `stage='done'` — BR-7 — поэтому обычный gate её
|
||||
не удержит; нужен отдельный сигнал).
|
||||
- Снятие freeze — **ручное** (оператор). Способ снятия (эндпоинт/админ-команда/ручная правка БД/
|
||||
Plane-жест) определяет архитектор; требование — снятие должно быть простым, явным и наблюдаемым.
|
||||
- Алерт: Telegram (`send_telegram`/`notify_*`) + Plane `Blocked` для деградировавшей задачи (как
|
||||
ORCH-021), плюс явное сообщение «пакет заморожен, следующая задача не стартует до ручного снятия».
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
### 4.1. Новые публичные endpoint'ы
|
||||
- **Нет обязательных новых endpoint'ов.** (Снятие freeze может быть реализовано как админ-эндпоинт —
|
||||
на усмотрение архитектора; если вводится, описать в ADR и обновить таблицу API в README.)
|
||||
|
||||
### 4.2. Изменяемые endpoint'ы
|
||||
- `GET /queue` — **аддитивно** добавляется блок `serial_gate` (read-only снимок), по образцу блоков
|
||||
`task_deps` / `reconcile` / `post_deploy`:
|
||||
- `enabled` (флаг), `repos` (область),
|
||||
- per-repo: `active_task` (`{work_item_id, stage}` или `null`), `waiting` (список ожидающих
|
||||
задач/job'ов репо), `frozen` (bool) + причина/таймстамп freeze.
|
||||
- never-raise: при ошибке — минимальный словарь с флагами и пустыми данными.
|
||||
- Контракт `GET /queue` — **расширяется аддитивно**, существующие ключи не меняются.
|
||||
|
||||
### 4.3. Webhook-обработчики
|
||||
- `start_pipeline` / `handle_status_start` (`webhooks/plane.py`): добавляется ветвление «репо занят/
|
||||
заморожен → отложить старт/срез ветки, поставить в очередь ожидания» вместо немедленного
|
||||
`_create_gitea_branch` + enqueue. Внешний контракт вебхука Plane не меняется.
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
> Только **аддитивные, идемпотентные** миграции (общая прод-БД, enduro не трогать). Без изменения
|
||||
> существующих таблиц-контрактов.
|
||||
|
||||
- **Freeze-состояние (FR-5):** требуется durable per-repo признак заморозки. Варианты (выбор —
|
||||
архитектор): новая таблица `repo_freeze(repo TEXT, frozen_at TEXT, reason TEXT, work_item_id TEXT,
|
||||
cleared_at TEXT)` **или** аддитивная колонка в существующей таблице. Требования к выбранному варианту:
|
||||
идемпотентная миграция (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), restart-safe, per-repo.
|
||||
- **Активная задача репо:** **новых колонок НЕ требуется** — вычисляется из существующих
|
||||
`tasks(repo, stage)`.
|
||||
- **Очередь ожидания:** переиспользовать существующую `jobs` (status='queued' + gate в claim) — новой
|
||||
таблицы очереди **не вводить** (FR-2 решается gate'ом, не отдельной структурой).
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS`, `tasks`-контракт, `job_deps`, `agent_runs` — **без изменений**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Требования к срезу ветки (`git_worktree` / launcher)
|
||||
- Для новой задачи, чья ветка создаётся после открытия gate: перед срезом — `git fetch origin`
|
||||
(уже есть в `ensure_worktree`), база — `origin/main` HEAD.
|
||||
- Гарантировать, что ветка НЕ присоединяется к stale Gitea-ветке, созданной раньше времени: либо не
|
||||
создавать Gitea-ветку преждевременно (отсрочка §3.3), либо при материализации worktree база
|
||||
безусловно = свежий `origin/main` (включающий предшественника).
|
||||
- Никогда не push/force-push в `main`. Существующие merge-lease / auto_rebase (ORCH-026/043) не
|
||||
трогаются.
|
||||
|
||||
---
|
||||
|
||||
## 7. Требования к новым QG checks
|
||||
- **Новых QG-проверок не вводить.** Gate — это условие планировщика (claim / старт), а **не**
|
||||
Quality Gate стадии. Реестр `QG_CHECKS` и `check_*` не меняются (как `task_deps` ORCH-026 —
|
||||
gate в claim, не новый QG).
|
||||
|
||||
## 8. Конфигурация (`src/config.py`)
|
||||
По образцу `task_deps_enabled` / `merge_gate_*` / `post_deploy_*`:
|
||||
- `serial_gate_enabled: bool = True` (env `ORCH_SERIAL_GATE_ENABLED`) — kill-switch; `False` → claim и
|
||||
старт ведут себя строго как сейчас (нулевая регрессия, NFR-4).
|
||||
- `serial_gate_repos: str = ""` (env `ORCH_SERIAL_GATE_REPOS`, CSV) — область; пусто → применять как
|
||||
по умолчанию (см. ниже).
|
||||
- Helper `serial_gate_applies(repo) -> bool` (leaf-модуль, never-raise) по образцу `post_deploy_applies`:
|
||||
`enabled` + (если CSV непуст — членство репо; иначе — область по умолчанию).
|
||||
- **Область по умолчанию (решение для ADR):** serial gate осмыслен для ВСЕХ репо (FR-3 — и orchestrator,
|
||||
и enduro выигрывают от serial e2e), в отличие от self-hosting-only гейтов (ORCH-35/43/58). Рекомендация:
|
||||
пустой CSV → применять ко всем зарегистрированным репо. Архитектор фиксирует и обосновывает в ADR.
|
||||
- При необходимости — отдельные флаги для freeze (FR-5), например `serial_gate_freeze_enabled`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Наблюдаемость и алерты
|
||||
- `GET /queue` блок `serial_gate` (см. §4.2).
|
||||
- Лог: каждое решение «gate закрыт, задача отложена» и «freeze выставлен/снят» → `logger.info/warning`.
|
||||
- Telegram: freeze (выставление) → алерт (`send_telegram`/`notify_*`); карточка задачи (ORCH-042/087)
|
||||
может отражать «⏳ ждёт завершения <work_item_id>» (по образцу строки `task_deps` «⏳ ждёт ORCH-NNN»),
|
||||
never-raise.
|
||||
|
||||
---
|
||||
|
||||
## 10. Артефакты pipeline (создать/обновить в ТОМ ЖЕ PR)
|
||||
Документация — golden source (CLAUDE.md §2). По итогам разработки обновить:
|
||||
- `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md` — решение (механизм отсрочки ветки, freeze-
|
||||
хранилище, область по умолчанию, точки врезки).
|
||||
- `docs/architecture/README.md` — новый раздел «Serial gate (ORCH-088)» + строка статуса доработок;
|
||||
обновить описание `GET /queue` (блок `serial_gate`) и раздел «База данных», если добавлена таблица.
|
||||
- `CLAUDE.md` — краткий абзац о serial-режиме (если уместно в паспорте).
|
||||
- `CHANGELOG.md` — запись `feat:`.
|
||||
- При новой таблице freeze — `docs/work-items/ORCH-088/08-data-requirements.md`.
|
||||
- При новом админ-эндпоинте снятия freeze — обновить таблицу API в README.
|
||||
|
||||
---
|
||||
|
||||
## 11. Инварианты (не нарушать)
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды deploy-хука, merge-gate (ORCH-043),
|
||||
merge-verify (ORCH-071/073), image-freshness (ORCH-058), post-deploy контракт (ORCH-021),
|
||||
`max_concurrency` — **без изменений**.
|
||||
- never-raise на единицу работы; claim fail-**open** на ошибке БД (NFR-1); freeze fail-**closed**.
|
||||
- Offline в горячем claim (NFR-2): без сетевых вызовов Plane/Gitea.
|
||||
- Не рестартить/не ронять прод-контейнер (CLAUDE.md self-hosting).
|
||||
- Миграции аддитивны и идемпотентны; enduro при выключенном/неприменимом флаге не затрагивается.
|
||||
|
||||
---
|
||||
|
||||
## 12. Открытые вопросы для архитектора (не блокируют анализ)
|
||||
- OQ-1: Механизм отсрочки среза ветки — отложить `_create_gitea_branch` в `start_pipeline` ИЛИ
|
||||
перенести материализацию ветки на claim analyst-job? (Влияет на AC-6 и на то, где живёт «ожидающая»
|
||||
задача — в Plane-статусе vs как `queued` job без ветки.)
|
||||
- OQ-2: Хранилище freeze — отдельная таблица `repo_freeze` vs колонка.
|
||||
- OQ-3: Способ ручного снятия freeze (эндпоинт / Plane-жест / админ-команда).
|
||||
- OQ-4: Поведение при задаче в Blocked/Needs-Input, держащей gate закрытым (Этап 1 — держит; нужен ли
|
||||
отдельный «вывод из учёта активных» — вероятно нет, фиксируем как осознанное).
|
||||
- OQ-5: Область по умолчанию (все репо vs только self-hosting) — рекомендация §8.
|
||||
103
docs/work-items/ORCH-088/03-acceptance-criteria.md
Normal file
103
docs/work-items/ORCH-088/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-088 — Serial gate
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
Формат: каждый критерий — чёткое условие **PASS/FAIL**. Критерий считается выполненным, если
|
||||
описанная проверка даёт указанный результат. Нумерация AC-1…AC-6 соответствует BR; AC-7…AC-11 —
|
||||
производные/защитные.
|
||||
|
||||
> Скоп — FR-1…FR-5 (serial e2e). Merge-очередь / pre-merge rebase / фазы A/B/C — вне приёмки.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Gate закрыт при активной задаче
|
||||
**Условие:** в репо `R` есть задача `A` со `stage != 'done'`. В очередь поступает новая задача `B`
|
||||
того же репо.
|
||||
- **PASS:** analyst-агент задачи `B` НЕ запускается; ветка `B` НЕ создаётся; `B` остаётся в ожидании
|
||||
(`jobs.status='queued'` / не стартована). `GET /queue` показывает `B` как ожидающую.
|
||||
- **FAIL:** analyst `B` стартовал, или ветка `B` создана, пока `A` не `done`.
|
||||
|
||||
## AC-2 — Автостарт следующей по достижении `done`
|
||||
**Условие:** активная задача `A` репо `R` достигла `stage = 'done'` (после прод-деплоя). В очереди
|
||||
ждёт `B`.
|
||||
- **PASS:** `B` стартует анализ **автоматически** (без ручного действия) — claim analyst-job `B`
|
||||
происходит на ближайшем цикле планировщика; ветка `B` создаётся в этот момент.
|
||||
- **FAIL:** `B` не стартует после `A.stage='done'`, либо для старта требуется ручное вмешательство.
|
||||
|
||||
## AC-3 — Restart-safe (состояние в БД)
|
||||
**Условие:** активна `A` (`stage<done`), `B` ждёт; оркестратор перезапускается.
|
||||
- **PASS:** после рестарта gate по-прежнему закрыт (`B` не стартовала, `A` определяется из БД);
|
||||
после `A.stage='done'` `B` стартует. Freeze (если был выставлен) сохраняется после рестарта.
|
||||
- **FAIL:** после рестарта `B` стартовала при `A.stage<done`, или freeze «потерян».
|
||||
|
||||
## AC-4 — Per-repo параллелизм
|
||||
**Условие:** активна задача в `orchestrator` (`stage<done`); в `enduro-trails` поступает новая задача.
|
||||
- **PASS:** задача `enduro-trails` стартует анализ независимо (gate orchestrator её не держит) и
|
||||
наоборот; gate/freeze одного репо не влияет на другой.
|
||||
- **FAIL:** задача другого репо заблокирована состоянием gate/freeze чужого репо.
|
||||
|
||||
## AC-5 — Rollback-freeze + алерт
|
||||
**Условие:** задача `A` репо `R` достигла `done`; во время post-deploy мониторинга вынесен вердикт
|
||||
`DEGRADED` (self → `ALERT_ONLY`; non-self+auto_rollback → `ROLLBACK`).
|
||||
- **PASS:** для `R` выставлен durable freeze (в БД); отправлен Telegram-алерт о заморозке; следующая
|
||||
задача репо НЕ стартует, пока freeze не снят **вручную**; `GET /queue` показывает `frozen: true`.
|
||||
После ручного снятия freeze следующая задача стартует.
|
||||
- **FAIL:** следующая задача стартовала при активном freeze; либо freeze снялся автоматически; либо
|
||||
алерт не отправлен.
|
||||
|
||||
## AC-6 — Нет stale-base (ветка от свежего `main`)
|
||||
**Условие:** задачи `A` затем `B` одного репо проходят serial. `A` влита в `main` к моменту своего
|
||||
`done`.
|
||||
- **PASS:** ветка `B` срезана от `main`, **содержащего код `A`**: проверка
|
||||
`git merge-base --is-ancestor <validated_sha задачи A> <branch B>` (или равноценная: HEAD `A` в
|
||||
`main` — предок базы `B`) истинна. Branch `B` не создан раньше `A.stage='done'`.
|
||||
- **FAIL:** база `B` не содержит коммитов `A` (ветка срезана до завершения `A`).
|
||||
|
||||
## AC-7 — Kill-switch / нулевая регрессия
|
||||
**Условие:** `serial_gate_enabled = False` (или репо вне `serial_gate_repos`).
|
||||
- **PASS:** claim и старт ведут себя строго как до ORCH-088 (gate инертен); тесты прежнего поведения
|
||||
зелёные; enduro не затронут.
|
||||
- **FAIL:** при выключенном флаге поведение отличается от исходного.
|
||||
|
||||
## AC-8 — never-raise / fail-open для claim
|
||||
**Условие:** при вычислении gate происходит ошибка БД/логики в горячем пути claim.
|
||||
- **PASS:** ошибка перехвачена и залогирована; claim НЕ падает; для claim — поведение fail-open
|
||||
(очередь всех проектов не заклинивает). Конвейер enduro продолжает работать.
|
||||
- **FAIL:** ошибка gate роняет claim/воркер или заклинивает очередь.
|
||||
|
||||
## AC-9 — fail-closed для freeze
|
||||
**Условие:** при определении состояния freeze возникает сомнение/ошибка (например, не удалось
|
||||
достоверно прочитать признак).
|
||||
- **PASS:** в отношении freeze применяется консервативное (безопасное для прода) поведение — не
|
||||
стартовать следующую при невозможности подтвердить отсутствие freeze (зафиксировать в ADR/коде).
|
||||
- **FAIL:** при сомнении gate открывается и стартует следующую задачу.
|
||||
|
||||
## AC-10 — Наблюдаемость `GET /queue`
|
||||
**Условие:** запрос `GET /queue` при активной задаче и/или freeze.
|
||||
- **PASS:** ответ содержит аддитивный блок `serial_gate` с: `enabled`, областью, per-repo
|
||||
`active_task`, списком `waiting`, `frozen`. Существующие ключи `/queue` не изменены.
|
||||
- **FAIL:** блок отсутствует/ломает существующий контракт, либо данные не отражают реальное состояние.
|
||||
|
||||
## AC-11 — Инварианты неизменны
|
||||
**Условие:** проверка контрактов после внедрения.
|
||||
- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды deploy-хука, merge-gate,
|
||||
merge-verify, image-freshness, post-deploy контракт — без изменений; миграции БД аддитивны и
|
||||
идемпотентны; прод-контейнер не рестартится механизмом gate.
|
||||
- **FAIL:** изменён любой перечисленный контракт, либо миграция не идемпотентна.
|
||||
|
||||
---
|
||||
|
||||
## Сводная матрица AC ↔ FR/BR
|
||||
| AC | FR | BR | Тип проверки |
|
||||
|----|----|----|--------------|
|
||||
| AC-1 | FR-1 | BR-1 | unit (claim/gate) + integration |
|
||||
| AC-2 | FR-1/2 | BR-2 | integration |
|
||||
| AC-3 | FR-4 | BR-5 | integration (restart) |
|
||||
| AC-4 | FR-3 | BR-4 | unit + integration |
|
||||
| AC-5 | FR-5 | BR-6/7 | integration |
|
||||
| AC-6 | FR-1 | BR-3 | integration (git base) |
|
||||
| AC-7 | — | BR-8 | unit |
|
||||
| AC-8 | — | NFR-1 | unit |
|
||||
| AC-9 | FR-5 | NFR-1 | unit |
|
||||
| AC-10 | — | BR-9 | unit (snapshot) |
|
||||
| AC-11 | — | NFR-5 | unit (контракты) |
|
||||
153
docs/work-items/ORCH-088/04-test-plan.yaml
Normal file
153
docs/work-items/ORCH-088/04-test-plan.yaml
Normal file
@@ -0,0 +1,153 @@
|
||||
work_item: ORCH-088
|
||||
title: "Serial gate (Этап 1: пакетный автономный режим, serial e2e)"
|
||||
scope: "FR-1..FR-5 only. Merge-queue / pre-merge rebase / phases A/B/C / ORCH-83 — out of scope."
|
||||
framework: pytest
|
||||
|
||||
# Принципы тестирования:
|
||||
# - чистую логику gate/freeze покрываем unit-тестами на leaf-функциях (без сети/БД где можно);
|
||||
# - claim-gate и e2e-последовательность — integration на временной SQLite-БД;
|
||||
# - все тесты детерминированы (без реальных Plane/Gitea/прод вызовов — мокируются);
|
||||
# - проверяем оба направления kill-switch (вкл/выкл) и never-raise.
|
||||
|
||||
tests:
|
||||
# ---------- FR-1 / AC-1: gate закрыт при активной задаче ----------
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "claim_next_job НЕ выбирает analyst-job новой задачи B, если в репо есть задача A со stage!='done' (gate закрыт)"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "serial_gate_applies(repo): enabled + пустой CSV → True для зарегистрированного репо; CSV с членством → True; репо вне CSV → False"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Job'ы УЖЕ активной задачи (architect/developer/.../deployer) gate'ом НЕ блокируются — единственная активная задача свободно идёт по конвейеру"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-1/2 / AC-2: автостарт следующей по достижении done ----------
|
||||
- id: TC-04
|
||||
type: integration
|
||||
description: "После перевода A.stage='done' claim_next_job выбирает analyst-job ожидающей B того же репо (gate открылся автоматически)"
|
||||
module: tests/test_serial_gate_e2e.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: integration
|
||||
description: "Очередь из 3 задач одного репо обрабатывается строго по одной: пока A не done, ни B, ни C не стартуют; порядок FIFO по jobs.id"
|
||||
module: tests/test_serial_gate_e2e.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-4 / AC-3: restart-safe ----------
|
||||
- id: TC-06
|
||||
type: integration
|
||||
description: "Активная задача определяется из БД (tasks.repo + stage!='done'), не из in-memory — после пересоздания воркера/состояния gate остаётся закрытым при A.stage<done"
|
||||
module: tests/test_serial_gate_e2e.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: "Freeze переживает рестарт: выставленный в БД freeze читается после пересоздания состояния; следующая задача не стартует"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-3 / AC-4: per-repo ----------
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Активная задача в orchestrator (stage<done) НЕ блокирует claim analyst-job задачи в enduro-trails (gate фильтруется по repo)"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Freeze репо orchestrator не влияет на claim/старт задач enduro-trails"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-5 / AC-5: rollback-freeze + алерт ----------
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "post-deploy вердикт DEGRADED → выставляется durable per-repo freeze (запись в БД) + вызывается Telegram-алерт (send_telegram замокан, проверяется вызов)"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "При активном freeze репо claim_next_job НЕ выбирает analyst-job следующей задачи, даже если нет задач stage<done (деградировавшая уже done — BR-7)"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "Ручное снятие freeze → следующая задача стартует на ближайшем цикле; freeze помечается cleared в БД"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-1 / AC-6: нет stale-base ----------
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Ветка B не создаётся (ни Gitea-ветка, ни worktree), пока gate закрыт — _create_gitea_branch/ensure_worktree для B не вызывается при A.stage<done"
|
||||
module: tests/test_serial_gate_branch.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: integration
|
||||
description: "После A.stage='done' (A влита в main) база ветки B = origin/main с кодом A: git merge-base --is-ancestor <sha A> <base B> истинно (на временном git-репо)"
|
||||
module: tests/test_serial_gate_branch.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-7: kill-switch / нулевая регрессия ----------
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "serial_gate_enabled=False → claim_next_job SQL/поведение идентичны исходным (gate инертен); B стартует независимо от A"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "Репо вне serial_gate_repos (CSV непуст) → gate не применяется к этому репо"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-8 / AC-9: never-raise ----------
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "Ошибка БД при вычислении gate в claim → перехвачена, залогирована, claim не падает (fail-OPEN: claim продолжается)"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "Ошибка при определении freeze → fail-CLOSED: следующая не стартует при невозможности подтвердить отсутствие freeze"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-10: наблюдаемость ----------
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: "serial_gate snapshot() возвращает {enabled, repos, per-repo active_task, waiting, frozen}; never-raise при ошибке → минимальный словарь"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-20
|
||||
type: integration
|
||||
description: "GET /queue содержит аддитивный блок serial_gate и НЕ меняет существующие ключи (counts/max_concurrency/reconcile/reaper/post_deploy/task_deps/recent)"
|
||||
module: tests/test_queue_endpoint.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-11: инварианты ----------
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "STAGE_TRANSITIONS и реестр QG_CHECKS не изменены (снимок ключей совпадает с эталоном); новых QG-проверок нет"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "Миграция freeze-хранилища идемпотентна: повторный вызов init_db/_ensure не падает и не дублирует структуру"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
221
docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md
Normal file
221
docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# ADR-001: Per-repo serial gate + deferred branch cut + durable rollback-freeze (ORCH-088, Этап 1)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** (self-hosting) · Стадия: architecture
|
||||
Связь: BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, данные `08-data-requirements.md`, риски `10-tech-risks.md`.
|
||||
Сквозная регистрация: `docs/architecture/adr/adr-0017-serial-gate.md`.
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
Эпик ORCH-088 (Этап 1, serial e2e) требует обрабатывать пакет из 10–20 задач **строго по одной**
|
||||
end-to-end и устранить **stale-анализ**: ветка задачи N+1 не должна срезаться от `main`, ещё не
|
||||
содержащего код предшественника N (BRD §1.2). Физическое код-затирание при параллельном merge уже
|
||||
закрыто (ORCH-026 auto_rebase + merge-lease); ORCH-088 закрывает **логический** разрыв.
|
||||
|
||||
Корень проблемы (проверено в коде):
|
||||
1. `webhooks/plane.py::start_pipeline` при переводе issue в анализ:
|
||||
`create_task_atomic(stage='analysis')` → **`_create_gitea_branch(repo, branch)`** (срез Gitea-ветки
|
||||
от `main` в момент T0) → `_create_initial_docs(...)` → `enqueue_job("analyst", ...)`.
|
||||
2. Позже `agents/launcher._spawn` зовёт `git_worktree.ensure_worktree(repo, branch)`, который при
|
||||
**существующей** Gitea-ветке делает `fetch + checkout <branch>` — **присоединяется к stale-ветке**,
|
||||
а не режет свежую. `ensure_worktree` режет от `origin/main` (`git worktree add -b … origin/main`)
|
||||
**только если ветки ещё нет** (git_worktree.py L84-86).
|
||||
|
||||
⇒ Ключ к AC-6: **ветка не должна быть создана раньше, чем предшественник `done`.** Гейтить только
|
||||
claim недостаточно (R-1) — к этому моменту ветка уже срезана.
|
||||
|
||||
Существующий каркас для переиспользования: persistent-очередь ORCH-1 (`jobs`, atomic claim,
|
||||
restart-safe), gate-в-claim ORCH-026 (`task_deps` `NOT EXISTS`), leaf-паттерн `src/task_deps.py` /
|
||||
`src/post_deploy.py` (never-raise), наблюдаемость `GET /queue`, `max_concurrency=1`.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
Вводится **per-repo serial gate** двумя согласованными механизмами, аддитивно, под kill-switch,
|
||||
с областью репо, never-raise, restart-safe. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` — без изменений.
|
||||
|
||||
1. **Gate-в-claim** (`db.claim_next_job`): analyst-job новой задачи **не выбирается**, пока в том же
|
||||
репо есть другая незавершённая задача ИЛИ репо заморожен. Job уже активной задачи
|
||||
(architect/developer/…/deployer) проходят свободно.
|
||||
2. **Отложенный срез ветки**: для применимого репо `start_pipeline` **не** создаёт Gitea-ветку и docs;
|
||||
создание ветки+docs **релоцируется** в момент claim analyst-job (launcher), когда `origin/main`
|
||||
уже содержит предшественника. Это и даёт AC-6 структурно.
|
||||
3. **Durable per-repo freeze** (`repo_freeze`): при post-deploy `DEGRADED` репо замораживается; gate
|
||||
закрыт безусловно до **ручного** снятия.
|
||||
|
||||
Чистая логика — **новый leaf-модуль `src/serial_gate.py`** (never-raise, по образцу
|
||||
`src/task_deps.py` / `src/post_deploy.py`).
|
||||
|
||||
### D1 — Где гейтить вход в анализ: claim + relocation среза ветки (OQ-1)
|
||||
**Решение: релоцировать срез ветки на claim analyst-job, гейтить в `claim_next_job`.**
|
||||
|
||||
- `claim_next_job` получает дополнительный SQL-фрагмент `serial_gate` (строится как существующий
|
||||
`dep_gate`, конкатенацией; только локальная БД — NFR-2 offline). Условие «**не** выбирать job,
|
||||
если это analyst-job применимого репо, у которого есть конфликт»:
|
||||
|
||||
```
|
||||
AND NOT (
|
||||
jobs.agent = 'analyst'
|
||||
{repo_scope} -- "" при пустом CSV (все репо); иначе AND jobs.repo IN (<sanitized>)
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM tasks t2
|
||||
WHERE t2.repo = jobs.repo
|
||||
AND t2.id != jobs.task_id -- «другая» задача (rework-analyst своей же задачи не блокирует себя)
|
||||
AND t2.stage != 'done')
|
||||
OR EXISTS (SELECT 1 FROM repo_freeze f
|
||||
WHERE f.repo = jobs.repo AND f.cleared_at IS NULL)
|
||||
)
|
||||
)
|
||||
```
|
||||
- Гейт **только для `jobs.agent='analyst'`** — вход в анализ. Прочие роли активной задачи проходят
|
||||
(иначе единственная активная задача не сдвинется). Rework-analyst (`start_pipeline` rejection-path,
|
||||
re-enqueue analyst той же задачи) не блокируется собой за счёт `t2.id != jobs.task_id`.
|
||||
- **Relocation среза ветки**: для применимого репо `start_pipeline` создаёт task-row
|
||||
(`stage='analysis'`) и enqueue analyst-job, но **НЕ** зовёт `_create_gitea_branch` /
|
||||
`_create_initial_docs`. Эти два вызова переносятся в путь spawn analyst-job (launcher), выполняясь
|
||||
**в момент claim** — когда `origin/main` уже включает предшественника (gate открылся ⇒ предшественник
|
||||
`done` ⇒ merge-verify ORCH-071 гарантировал SHA-в-main). Последовательность на claim сохраняется
|
||||
идентичной нынешней: `_create_gitea_branch` (от свежего `main`) → `_create_initial_docs` →
|
||||
`ensure_worktree` (fetch+checkout только что созданной ветки) ⇒ база = свежий `origin/main`.
|
||||
- **Идемпотентность**: `_create_gitea_branch` уже обрабатывает 409 «branch exists» как no-op ⇒ повтор
|
||||
claim (рестарт/реклейм) безопасен без флага в БД. AC-6 проверяемо:
|
||||
`git merge-base --is-ancestor <validated_sha A> <base B>`.
|
||||
|
||||
**Почему не «гейт в `start_pipeline` + отложенный re-trigger»** (альтернатива OQ-1.A): webhook Plane —
|
||||
one-shot; отложенная задача потребовала бы отдельного re-trigger (reconciler/done-hook) и псевдо-стадии
|
||||
ожидания → больше состояния, больше путей, выше риск «зависшей» задачи. Relocation на claim
|
||||
переиспользует уже-restart-safe `jobs`-очередь: ожидающая задача = `queued` analyst-job без ветки;
|
||||
открытие gate = обычный claim на ближайшем тике планировщика (AC-2, AC-3 «бесплатно»).
|
||||
|
||||
### D2 — Хранилище freeze: отдельная таблица `repo_freeze` (OQ-2)
|
||||
**Решение: новая аддитивная таблица** `repo_freeze(repo, frozen_at, reason, work_item_id, cleared_at)`
|
||||
(детали — `08-data-requirements.md`). Активный freeze ⇔ `cleared_at IS NULL`. Колонка на `tasks`
|
||||
отвергнута: freeze — **per-repo** сигнал, а деградировавшая задача к этому моменту уже `stage='done'`
|
||||
(BR-7) — привязка к задаче семантически неверна. Таблица — append-only журнал (история заморозок,
|
||||
наблюдаемость), идемпотентная миграция `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
### D3 — Выставление freeze (FR-5)
|
||||
В `stage_engine.run_post_deploy_monitor` в ветке вердикта `DEGRADED` (после реакции
|
||||
`ALERT_ONLY`/`ROLLBACK*`, рядом с `set_issue_blocked`, L1702-1715) — вызов
|
||||
`serial_gate.set_repo_freeze(repo, reason, work_item_id)` (never-raise) + Telegram-алерт
|
||||
«пакет заморожен, следующая задача не стартует до ручного снятия» (reuse `send_telegram`/`_notify_post_deploy`).
|
||||
Freeze **durable** (БД), self-hosting прод **не** рестартится/не роняется (NFR-6) — freeze есть
|
||||
пассивная остановка стартов, не действие над прод.
|
||||
|
||||
### D4 — Снятие freeze: явный админ-эндпоинт (OQ-3)
|
||||
**Решение: `POST /serial-gate/unfreeze` (body/query `repo=<repo>`)** → `serial_gate.clear_repo_freeze(repo)`
|
||||
(ставит `cleared_at=now` всем активным строкам репо) + лог + Telegram-подтверждение. Аутентификация —
|
||||
по существующему админ-механизму сервиса (тот же секрет/доступ, что у управляющих ручек; developer
|
||||
согласует с текущей поверхностью). Альтернативой допускается ручная правка БД
|
||||
(`UPDATE repo_freeze SET cleared_at=…`) — задокументировать в README. Снятие — простое, явное,
|
||||
наблюдаемое (`GET /queue`). Plane-жест как триггер снятия **отвергнут** (перегрузка статусов —
|
||||
анти-паттерн ORCH-059).
|
||||
|
||||
### D5 — Область по умолчанию: все зарегистрированные репо (OQ-5)
|
||||
**Решение: пустой `serial_gate_repos` ⇒ применять ко ВСЕМ репо** (а не self-hosting-only как
|
||||
ORCH-35/43/58). Обоснование: serial e2e и анти-stale-base полезны и enduro-trails (FR-3), у каждого
|
||||
репо свой `main`. Cross-repo независимость сохраняется самим условием (`t2.repo = jobs.repo`). Оператор
|
||||
может сузить область CSV (`ORCH_SERIAL_GATE_REPOS=orchestrator`), если хочет оставить enduro
|
||||
без serial. «Нулевая регрессия для enduro» (BR-8/NFR-4) относится к **выключенному** kill-switch.
|
||||
|
||||
### D6 — Blocked/Needs-Input держит gate закрытым (OQ-4)
|
||||
**Решение (Этап 1): осознанно держит.** Задача в Blocked/Needs-Input имеет `stage != 'done'` ⇒
|
||||
участвует в `EXISTS` ⇒ gate закрыт. Пакет не движется, пока оператор не доведёт задачу до прод или не
|
||||
закроет. Отдельный «вывод из учёта активных» — вне скопа (зафиксировано в AC, BRD §6). Наблюдаемость
|
||||
(`GET /queue` + Telegram-карточка «⏳ ждёт …») делает залипание видимым (R-3).
|
||||
|
||||
### D7 — Конфигурация (`src/config.py`)
|
||||
По образцу `task_deps_*` / `post_deploy_*`:
|
||||
- `serial_gate_enabled: bool = True` (`ORCH_SERIAL_GATE_ENABLED`) — kill-switch. `False` ⇒ `claim` и
|
||||
`start_pipeline` 1:1 как сейчас (ветка режется в `start_pipeline`, gate-фрагмент опущен) — NFR-4/AC-7.
|
||||
- `serial_gate_repos: str = ""` (`ORCH_SERIAL_GATE_REPOS`, CSV) — область; пусто ⇒ все репо (D5).
|
||||
- `serial_gate_freeze_enabled: bool = True` (`ORCH_SERIAL_GATE_FREEZE_ENABLED`) — независимый тумблер
|
||||
freeze-слоя (FR-5) для поэтапного раската; `False` ⇒ freeze не выставляется/не учитывается.
|
||||
- Helper `serial_gate.serial_gate_applies(repo) -> bool` (never-raise): `enabled` + (CSV непуст →
|
||||
членство; иначе True).
|
||||
|
||||
### D8 — Leaf-модуль `src/serial_gate.py` (never-raise)
|
||||
Публичный контракт (вся логика без сети, только БД/config):
|
||||
- `serial_gate_applies(repo) -> bool`
|
||||
- `repo_has_active_task(repo, exclude_task_id=None) -> bool` — `EXISTS tasks stage!='done'`
|
||||
- `is_repo_frozen(repo) -> bool` — **fail-CLOSED** (ошибка/сомнение → `True`, AC-9)
|
||||
- `set_repo_freeze(repo, reason, work_item_id)` / `clear_repo_freeze(repo)`
|
||||
- `build_claim_clause() -> str` — SQL-фрагмент для `claim_next_job` (санитизация repo-токенов
|
||||
`^[A-Za-z0-9._-]+$` перед встраиванием в `IN (...)`; невалидный токен дропается)
|
||||
- `snapshot() -> dict` — per-repo `{active_task, waiting, frozen, frozen_reason, frozen_at}` для `/queue`
|
||||
|
||||
### D9 — Наблюдаемость `GET /queue` (BR-9, AC-10)
|
||||
Аддитивный блок `serial_gate`: `{enabled, repos, per_repo: {<repo>: {active_task:{work_item_id,stage}|null,
|
||||
waiting:[…], frozen:bool, frozen_reason, frozen_at}}}`. never-raise: при ошибке — минимальный словарь
|
||||
с флагами и пустыми данными. Существующие ключи `/queue` не меняются.
|
||||
|
||||
### D10 — Согласование fail-open (claim) ↔ fail-closed (freeze) — NFR-1
|
||||
Два требования действуют на разных слоях, без противоречия:
|
||||
- **Hot-claim, тотальный сбой gate-запроса** ⇒ **fail-OPEN**: весь `serial_gate`-фрагмент строится
|
||||
через `try/except` в `build_claim_clause`; любая ошибка построения → пустой фрагмент → claim как без
|
||||
gate. Заклинивание очереди ВСЕХ проектов (включая enduro) хуже, чем разовый риск stale-base (AC-8).
|
||||
- **Freeze-решение в Python-слое** (`is_repo_frozen`, deferral-решение, snapshot) ⇒ **fail-CLOSED**:
|
||||
невозможность подтвердить отсутствие freeze → считать замороженным, не стартовать (AC-9, безопасность
|
||||
прода). Когда freeze реально выставлен, строка `repo_freeze` существует и блокирует в самом SQL —
|
||||
fail-open в claim касается лишь тотального сбоя запроса (транзиент), что приемлемо.
|
||||
|
||||
---
|
||||
|
||||
## Точки врезки (для разработчика)
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `src/serial_gate.py` | **новый** leaf-модуль (D8) |
|
||||
| `src/db.py` | миграция `repo_freeze` (idempotent); `serial_gate` фрагмент в `claim_next_job` (D1); read-only helper'ы выборки активной задачи/freeze |
|
||||
| `src/config.py` | `serial_gate_enabled` / `serial_gate_repos` / `serial_gate_freeze_enabled` (D7) |
|
||||
| `src/webhooks/plane.py` | `start_pipeline`: для применимого репо **не** звать `_create_gitea_branch`/`_create_initial_docs`; оставить task-row + enqueue analyst (D1) |
|
||||
| `src/agents/launcher.py` | `_spawn` для `agent=='analyst'` применимого репо: материализовать ветку+docs (relocated `_create_gitea_branch`+`_create_initial_docs`) перед `ensure_worktree` (D1) |
|
||||
| `src/stage_engine.py` | `run_post_deploy_monitor` DEGRADED-ветка: `serial_gate.set_repo_freeze(...)` + алерт (D3) |
|
||||
| `src/main.py` | `GET /queue` блок `serial_gate` (D9); `POST /serial-gate/unfreeze` (D4) |
|
||||
| `src/notifications.py` | Telegram-карточка `⏳ ждёт завершения <wi>` (по образцу task_deps), best-effort |
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- AC-6 закрыт **структурно** (ветка не существует до открытия gate) — не «лечение следствия».
|
||||
- AC-2/AC-3 «бесплатны»: ожидание = `queued` analyst-job без ветки в restart-safe `jobs`-очереди;
|
||||
открытие gate = обычный claim. In-memory состояния нет (NFR-3).
|
||||
- Переиспользует проверенные паттерны (claim-gate ORCH-026, leaf never-raise, `/queue`-снимок).
|
||||
- `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/merge-gate/merge-verify/image-freshness/post-deploy/
|
||||
deploy-хук/`max_concurrency` — без изменений (NFR-5/AC-11).
|
||||
- Cross-repo параллелизм сохранён (FR-3/AC-4); enduro при выключенном флаге не затронут (NFR-4).
|
||||
|
||||
### Минусы / ограничения
|
||||
- Срез ветки и `_create_initial_docs` мигрируют из async-`start_pipeline` в sync-путь launcher —
|
||||
developer обязан обернуть Gitea-API вызовы для sync-контекста (httpx sync / `asyncio.run`); риск
|
||||
R-4 (см. `10-tech-risks.md`).
|
||||
- Между `start_pipeline` и claim analyst-job у задачи нет материализованной ветки — потребители,
|
||||
ожидающие ветку до запуска analyst, должны быть проверены (R-5).
|
||||
- Blocked-задача держит пакет (D6) — осознанный размен Этапа 1; требует операторского внимания.
|
||||
- Freeze снимается только вручную — «вечный freeze» при невнимании оператора (R-3, mitigation —
|
||||
наблюдаемость + алерт).
|
||||
|
||||
### Откат
|
||||
Полный откат — `serial_gate_enabled=False` (claim/старт 1:1 как сейчас) и/или
|
||||
`serial_gate_freeze_enabled=False`. Таблица `repo_freeze` инертна при выключенных флагах.
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
- **Гейт в `start_pipeline` + re-trigger при `done`** — больше состояния/путей, выше риск зависания (D1).
|
||||
- **Freeze как колонка `tasks`** — неверная семантика (freeze per-repo, задача уже `done`) (D2).
|
||||
- **Self-hosting-only область** (как ORCH-35/43/58) — лишает enduro анти-stale-base (D5).
|
||||
- **Снятие freeze Plane-жестом** — перегрузка статусов, анти-паттерн ORCH-059 (D4).
|
||||
- **Отдельная таблица очереди ожидания** — избыточно, `jobs`(queued)+gate достаточно (ТЗ §5).
|
||||
|
||||
## Связи
|
||||
- Переиспользует: ORCH-1 (очередь), ORCH-026 (claim-gate, auto_rebase/merge-lease), ORCH-021
|
||||
(post-deploy monitor — источник DEGRADED), ORCH-071/073 (merge-verify ⇒ `done` ⇔ SHA-в-main).
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0017-serial-gate.md`.
|
||||
- Не пересекается с merge-очередью/pre-merge rebase/фазами A/B/C — **вне скопа** Этапа 1.
|
||||
73
docs/work-items/ORCH-088/08-data-requirements.md
Normal file
73
docs/work-items/ORCH-088/08-data-requirements.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 08 — Требования к схеме БД: ORCH-088 (Serial gate, freeze-хранилище)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: architecture
|
||||
Связь: ADR `06-adr/ADR-001-serial-gate.md` (D2/D3/D4), ТЗ `02-trz.md` §5.
|
||||
|
||||
> Общая прод-БД (self-hosting обслуживает enduro-trails из того же инстанса). Все миграции —
|
||||
> **только аддитивные и идемпотентные** (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`). Изменение
|
||||
> существующих таблиц-контрактов (`tasks`, `jobs`, `job_deps`, `agent_runs`) запрещено.
|
||||
|
||||
---
|
||||
|
||||
## 1. Новая таблица `repo_freeze` (FR-5)
|
||||
|
||||
Durable per-repo признак заморозки пакета после post-deploy `DEGRADED`/rollback. Append-only журнал:
|
||||
активная заморозка ⇔ существует строка репо с `cleared_at IS NULL`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS repo_freeze (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo TEXT NOT NULL, -- ключ области (per-repo)
|
||||
frozen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reason TEXT, -- напр. "post-deploy DEGRADED 3/5"
|
||||
work_item_id TEXT, -- задача-источник деградации (уже stage='done')
|
||||
cleared_at TEXT -- NULL = freeze активен; снят оператором → datetime
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_freeze_active ON repo_freeze (repo, cleared_at);
|
||||
```
|
||||
|
||||
### Семантика
|
||||
- **Активный freeze репо `R`:** `EXISTS (SELECT 1 FROM repo_freeze WHERE repo=R AND cleared_at IS NULL)`.
|
||||
- **Выставление** (`set_repo_freeze`): INSERT новой строки (`cleared_at=NULL`). Повторный DEGRADED при
|
||||
уже активном freeze — допускается доп. строка (журнал) либо no-op при существующей активной (выбор
|
||||
разработчика; на gate не влияет — `EXISTS` идемпотентен).
|
||||
- **Снятие** (`clear_repo_freeze`): `UPDATE repo_freeze SET cleared_at=datetime('now')
|
||||
WHERE repo=? AND cleared_at IS NULL` (закрывает все активные строки репо). Идемпотентно (повтор → 0 rows).
|
||||
- **Read (gate/snapshot):** только `cleared_at IS NULL`-строки; `is_repo_frozen` — **fail-closed**
|
||||
(ошибка чтения → `True`, AC-9).
|
||||
|
||||
### Использование в горячем claim
|
||||
`db.claim_next_job` читает `repo_freeze` инлайн внутри `serial_gate`-фрагмента (только локальная БД,
|
||||
offline — NFR-2):
|
||||
```
|
||||
OR EXISTS (SELECT 1 FROM repo_freeze f WHERE f.repo = jobs.repo AND f.cleared_at IS NULL)
|
||||
```
|
||||
(внутри `AND NOT ( jobs.agent='analyst' AND … )` — см. ADR D1). Тотальный сбой построения фрагмента →
|
||||
fail-open для claim (AC-8); реально выставленная строка блокирует через сам SQL.
|
||||
|
||||
---
|
||||
|
||||
## 2. Активная задача репо — без новых колонок
|
||||
|
||||
«Репо занят» вычисляется из существующих столбцов `tasks(repo, stage)`:
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done')
|
||||
```
|
||||
Новых колонок/таблиц для «активной задачи» и «очереди ожидания» **не вводится**: ожидание = существующий
|
||||
`jobs.status='queued'` analyst-job + gate в claim (ТЗ §5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Идемпотентность и restart-safety
|
||||
- Миграция `repo_freeze` выполняется в общем init-пути схемы (`db.init_db`/`_ensure_*`), безопасна к
|
||||
повторному запуску (`IF NOT EXISTS`).
|
||||
- Всё состояние gate/freeze — в БД (нет in-memory) ⇒ после рестарта поведение идентично (NFR-3/AC-3):
|
||||
активная задача определяется из `tasks`, freeze — из `repo_freeze`, ожидающая задача — `queued` job.
|
||||
- При выключенных флагах (`serial_gate_enabled=False` / `serial_gate_freeze_enabled=False`) таблица
|
||||
инертна; enduro и существующие контракты не затрагиваются (NFR-4/AC-11).
|
||||
|
||||
---
|
||||
|
||||
## 4. Неизменяемые контракты
|
||||
`tasks`, `jobs`, `job_deps`, `agent_runs`, `tracker_messages` — схема **без изменений**.
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` — не БД, но также не меняются (NFR-5).
|
||||
29
docs/work-items/ORCH-088/10-tech-risks.md
Normal file
29
docs/work-items/ORCH-088/10-tech-risks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 10 — Технические риски: ORCH-088 (Serial gate)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: architecture
|
||||
Связь: ADR `06-adr/ADR-001-serial-gate.md`, ТЗ `02-trz.md` §11-12, BRD §8.
|
||||
|
||||
Оценка: **Вероятность** (Н/С/В) × **Влияние** (Н/С/В). Self-hosting: «Влияние В» = риск для конвейера
|
||||
ВСЕХ проектов (общий прод/БД/очередь).
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| **R-1** | **Stale-base сохраняется**, если ветка режется на входе (`_create_gitea_branch` в `start_pipeline`) до завершения предшественника — гейт только claim не лечит (BRD R-1, AC-6 FAIL). | С | В | Relocation среза ветки на claim analyst-job (ADR D1): `start_pipeline` не создаёт ветку для применимого репо; `ensure_worktree` режет от свежего `origin/main` уже после открытия gate. Тест AC-6: `git merge-base --is-ancestor <validated_sha A> <base B>`. |
|
||||
| **R-2** | Gate, ошибочно **fail-closed на транзиентной ошибке БД** в hot-claim, заклинивает очередь ВСЕХ проектов (enduro встаёт). | С | В | `build_claim_clause` обёрнут в try/except → ошибка построения = пустой фрагмент = claim без gate (**fail-open**, ADR D10/AC-8). Freeze fail-closed применяется только в Python-слое, не в тотальном сбое hot-claim. Unit-тест: исключение в построении clause → claim не падает, выбирает job. |
|
||||
| **R-3** | **«Вечный freeze» / залипшая Blocked-задача** останавливает пакет незаметно (D6 — Blocked держит gate). | С | С | Наблюдаемость `GET /queue` блок `serial_gate` (`active_task`, `waiting`, `frozen`+reason); Telegram-алерт при выставлении freeze и карточка «⏳ ждёт …»; ручное снятие — простой эндпоинт `POST /serial-gate/unfreeze` (D4). Оператор видит причину застоя. |
|
||||
| **R-4** | **Async→sync релокация** `_create_gitea_branch`/`_create_initial_docs`: эти вызовы async (httpx) в `start_pipeline`, а launcher `_spawn` — sync. Неверная обёртка → исключение/блок event-loop. | С | С | Developer оборачивает Gitea-API для sync-контекста (httpx sync client / `asyncio.run` в отдельном пути). Контракт launcher never-raise: сбой материализации ветки → лог + job в retry, прод не трогается. Тест: claim analyst-job создаёт ветку и worktree без падения. |
|
||||
| **R-5** | **Потребитель ожидает материализованную ветку до запуска analyst** (между `start_pipeline` и claim ветки нет): Telegram-карточка / Plane-sync / reconciler могут предполагать существование ветки. | Н | С | Проверено: трекер/Plane-sync используют branch как строку имени, не git-ref. Перед разработкой — аудит читателей `tasks.branch`/Gitea-ветки на стадии до analyst. `start_pipeline` по-прежнему пишет `branch` в task-row (имя), не материализуя ref. |
|
||||
| **R-6** | **SQL-инъекция / поломка clause** через `serial_gate_repos` CSV при встраивании в `IN (...)`. | Н | С | Санитизация repo-токенов `^[A-Za-z0-9._-]+$` в `build_claim_clause` (ADR D8); невалидный токен дропается. CSV — операторский конфиг (не пользовательский ввод), риск низкий, но гард обязателен. Unit-тест на мусорный CSV. |
|
||||
| **R-7** | **Rework-analyst блокирует сам себя**: rejection-path `start_pipeline` re-enqueue analyst активной задачи; наивный gate «есть незавершённая задача репо» удержал бы её навсегда. | С | В | Условие `t2.id != jobs.task_id` (ADR D1) — учитываются только **другие** задачи. Unit-тест: rework-analyst задачи A при единственной незавершённой A — claim проходит. |
|
||||
| **R-8** | **Freeze не учитывает уже-`done` задачу**: деградировавшая задача к моменту DEGRADED уже `stage='done'` (BR-7) ⇒ обычный gate её не удержит, следующая стартует до выставления freeze (гонка). | Н | С | Freeze — **отдельный durable сигнал** (`repo_freeze`), не зависит от `stage` (ADR D2/D3). `set_repo_freeze` вызывается в DEGRADED-ветке монитора; до снятия gate закрыт безусловно. Возможная узкая гонка «`done`→claim next до записи freeze» приемлема Этапом 1 (следующий тик уже видит freeze); при необходимости — выставлять предупредительный freeze в начале окна мониторинга (вне скопа). |
|
||||
| **R-9** | **Default-all область** неожиданно сериализует enduro (меняет поведение при включении флага). | С | Н | Осознанное решение (ADR D5, ТЗ §8): enduro выигрывает от serial e2e; `max_concurrency=1` и так ограничивает параллелизм. Оператор может сузить `ORCH_SERIAL_GATE_REPOS=orchestrator`. «Нулевая регрессия» гарантирована при **выключенном** kill-switch (NFR-4). |
|
||||
| **R-10** | **Миграция на общей прод-БД** (`repo_freeze`) роняет init при неудачном порядке/блокировке. | Н | В | `CREATE TABLE IF NOT EXISTS` + idempotent index; выполняется в существующем init-пути схемы; аддитивно, не трогает enduro-строки. Прод-контейнер не рестартится механизмом gate (NFR-6). |
|
||||
|
||||
---
|
||||
|
||||
## Сводный вывод
|
||||
Архитектурно безопасных блокеров нет. Критические векторы — **R-1** (закрыт relocation среза ветки),
|
||||
**R-2/R-7** (закрыты fail-open hot-claim и `t2.id != jobs.task_id`). Все механизмы аддитивны, под
|
||||
kill-switch, never-raise, не рестартят прод. Главный операционный риск — **R-3** (ручной freeze),
|
||||
смягчён наблюдаемостью и алертами. Реализация — стандартный путь стадии development без эскалации
|
||||
`arch:major-change` (нет новой стадии/QG/смены БД-контракта; новая таблица аддитивна).
|
||||
85
docs/work-items/ORCH-088/12-review.md
Normal file
85
docs/work-items/ORCH-088/12-review.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-088
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-088 — Per-repo serial gate (Этап 1, serial e2e)
|
||||
|
||||
## Summary
|
||||
PR реализует per-repo serial gate (FR-1…FR-5) тремя согласованными механизмами в полном
|
||||
соответствии с ТЗ и ADR-001: gate-в-claim (`db.claim_next_job`), отложенный срез ветки
|
||||
(`start_pipeline` → `launcher._materialize_deferred_branch`) и durable rollback-freeze
|
||||
(`repo_freeze` + `POST /serial-gate/unfreeze`). Чистая логика вынесена в leaf-модуль
|
||||
`src/serial_gate.py` (never-raise). Полный прогон `pytest tests/ -q` — **1114 passed**;
|
||||
профильные сюиты (`test_serial_gate*`, `test_queue_endpoint`, `test_plane_webhook`,
|
||||
`test_status_trigger`) — 33 passed. Документация обновлена в том же PR. Блокеров нет.
|
||||
|
||||
## Оси проверки
|
||||
|
||||
### 1. Соответствие ТЗ / AC
|
||||
- FR-1 (gate на входе в анализ) — gate-фрагмент в `claim_next_job`, только `jobs.agent='analyst'`,
|
||||
только локальная БД (NFR-2). AC-1 ✓
|
||||
- FR-2 (очередь e2e, FIFO) — реализация уточняет псевдо-SQL ADR `t2.id != jobs.task_id` на
|
||||
`t2.id < jobs.task_id`. Уточнение **корректно и обосновано** (при `!=` пакет одновременно
|
||||
созданных задач взаимно блокируется → дедлок); задокументировано в коде, CHANGELOG и README.
|
||||
AC-2 ✓
|
||||
- FR-3 (per-repo) — все выборки фильтруются `t2.repo = jobs.repo`; cross-repo параллелизм
|
||||
сохранён. AC-4 ✓
|
||||
- FR-4 (restart-safe) — активная задача из `tasks`, freeze в `repo_freeze`; in-memory состояния
|
||||
нет. AC-3 ✓
|
||||
- FR-5 (rollback-freeze) — `set_repo_freeze` в DEGRADED-ветке `run_post_deploy_monitor` +
|
||||
Telegram-алерт; ручное снятие `POST /serial-gate/unfreeze`. AC-5 ✓
|
||||
- AC-6 (анти-stale-base) — закрыт **структурно**: ветка не создаётся до открытия gate
|
||||
(deferred cut в `_materialize_deferred_branch` от свежего `origin/main`). ✓
|
||||
- AC-7 (kill-switch/нулевая регрессия), AC-8 (fail-OPEN claim), AC-9 (fail-CLOSED freeze),
|
||||
AC-10 (`/queue` блок), AC-11 (инварианты) — все подтверждены кодом и тестами.
|
||||
|
||||
### 2. Соответствие ADR
|
||||
- D1–D10 реализованы как описано. Единственное отклонение — FIFO-условие `<` вместо `!=`
|
||||
(D1) — улучшает ADR, устраняет дедлок, явно задокументировано. Глобальный ADR
|
||||
`adr-0017-serial-gate.md` заведён и зарегистрирован.
|
||||
- `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / merge-gate / merge-verify / image-freshness /
|
||||
post-deploy / exit-коды хука — без изменений (AC-11). ✓
|
||||
|
||||
### 3. Качество кода
|
||||
- Leaf-модуль `src/serial_gate.py` — строгий never-raise; корректно разнесены направления
|
||||
отказа: claim — fail-OPEN (`build_claim_clause` → `""`), freeze — fail-CLOSED
|
||||
(`is_repo_frozen` → `True`). Санитизация repo-токенов `^[A-Za-z0-9._-]+$` перед встраиванием
|
||||
в SQL `IN (...)`.
|
||||
- Миграция `repo_freeze` аддитивна и идемпотентна (`CREATE TABLE/INDEX IF NOT EXISTS`).
|
||||
- `_materialize_deferred_branch` исполняется в worker-потоке (нет running loop) → `asyncio.run`
|
||||
безопасен; Gitea-вызовы идемпотентны (409/422 → no-op) → реклейм/рестарт безопасны; transient
|
||||
Gitea-ошибка пробрасывается → job переочередь (нет half-cut состояния).
|
||||
- Docstrings содержательны на всех публичных функциях.
|
||||
|
||||
### 4. Качество тестов
|
||||
Содержательные сюиты покрывают gate (claim), deferred branch, e2e, freeze, `/queue`-snapshot,
|
||||
webhook и status-trigger. Тесты не тривиальны (проверяют поведение, а не факт вызова).
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет — отмечено лишь как наблюдение) `_materialize_deferred_branch` делает два отдельных
|
||||
`asyncio.run` подряд; функционально корректно, можно объединить в один loop при будущем
|
||||
рефакторинге. Не блокирует.
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR — правило golden-source (CLAUDE.md §2) выполнено:
|
||||
- `docs/architecture/README.md` — новый раздел «Per-repo serial gate (ORCH-088)», обновлены
|
||||
таблица API (`GET /queue` + новый `POST /serial-gate/unfreeze`), раздел БД (`repo_freeze`),
|
||||
строка статуса доработок.
|
||||
- `CLAUDE.md` — абзац о serial-режиме в разделе «Очередь задач».
|
||||
- `CHANGELOG.md` — запись `feat:` (ORCH-088).
|
||||
- `.env.example` — три новых флага с описанием.
|
||||
- `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md` + сквозной
|
||||
`docs/architecture/adr/adr-0017-serial-gate.md` + `08-data-requirements.md`.
|
||||
|
||||
Изменения `src/` полностью отражены в документации → требование Reviewer §4 удовлетворено.
|
||||
94
docs/work-items/ORCH-088/13-test-report.md
Normal file
94
docs/work-items/ORCH-088/13-test-report.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-088
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-088 (Per-repo serial gate, Этап 1: serial e2e)
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; mode=AUTO)
|
||||
- Repo / ветка: `orchestrator` / `feature/ORCH-088-orch-88-10-20`
|
||||
- Дата: 2026-06-09T08:19Z
|
||||
|
||||
## Результаты
|
||||
|
||||
### Полный регресс
|
||||
`python -m pytest tests/ -v --tb=short` → **1114 passed, 1 warning, 31.52s**.
|
||||
Единственное предупреждение — известный `PydanticDeprecatedSince20` в `src/config.py:5`
|
||||
(не относится к ORCH-088).
|
||||
|
||||
### Профильные сюиты ORCH-088 (24 теста, 0 fail)
|
||||
`test_serial_gate*`, `test_queue_endpoint` → **24 passed, 1.39s**.
|
||||
|
||||
### Сопоставление с тест-планом `04-test-plan.yaml`
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-01 | gate закрыт при активной задаче (claim не берёт analyst B) | `test_serial_gate::test_gate_closed_when_repo_has_active_task` | PASS |
|
||||
| TC-02 | `serial_gate_applies`: enabled+пустой CSV/членство/вне CSV | `test_serial_gate::test_serial_gate_applies_scopes` | PASS |
|
||||
| TC-03 | job'ы уже активной задачи gate'ом не блокируются | `test_serial_gate::test_non_analyst_job_of_active_task_passes` | PASS |
|
||||
| TC-04 | автостарт B после A.stage='done' | `test_serial_gate_e2e::test_next_starts_automatically_when_predecessor_done` | PASS |
|
||||
| TC-05 | очередь из 3 задач — строго по одной, FIFO по jobs.id | `test_serial_gate_e2e::test_three_tasks_processed_one_at_a_time_fifo` | PASS |
|
||||
| TC-06 | restart-safe: активная задача из БД | `test_serial_gate_e2e::test_restart_safe_active_task_from_db` | PASS |
|
||||
| TC-07 | freeze переживает рестарт | `test_serial_gate_freeze::test_freeze_survives_restart` | PASS |
|
||||
| TC-08 | per-repo: orchestrator не блокирует enduro-trails | `test_serial_gate::test_per_repo_isolation` | PASS |
|
||||
| TC-09 | freeze orchestrator не влияет на enduro-trails | `test_serial_gate_freeze::test_freeze_is_per_repo` | PASS |
|
||||
| TC-10 | post-deploy DEGRADED → durable freeze + Telegram-алерт | `test_serial_gate_freeze::test_post_deploy_degraded_sets_freeze_and_alerts` | PASS |
|
||||
| TC-11 | freeze гейтит даже без задач stage<done (BR-7) | `test_serial_gate_freeze::test_freeze_gates_even_without_unfinished_task` | PASS |
|
||||
| TC-12 | ручное снятие freeze → следующая стартует | `test_serial_gate_freeze::test_manual_unfreeze_lets_next_start` | PASS |
|
||||
| TC-13 | ветка B не создаётся пока gate закрыт (отсрочка среза) | `test_serial_gate_branch::test_branch_cut_deferred_when_gate_applies` | PASS |
|
||||
| TC-14 | база B = origin/main с кодом A (merge-base ancestor) | `test_serial_gate_branch::test_deferred_branch_base_contains_predecessor` | PASS |
|
||||
| TC-15 | kill-switch off → claim инертен, нулевая регрессия | `test_serial_gate::test_kill_switch_off_is_inert` | PASS |
|
||||
| TC-16 | репо вне CSV → gate не применяется | `test_serial_gate::test_repo_outside_csv_not_gated` | PASS |
|
||||
| TC-17 | ошибка БД в claim → fail-OPEN, не падает | `test_serial_gate::test_build_clause_error_fails_open` | PASS |
|
||||
| TC-18 | ошибка freeze → fail-CLOSED | `test_serial_gate_freeze::test_is_repo_frozen_fails_closed` | PASS |
|
||||
| TC-19 | snapshot() shape + never-raise | `test_serial_gate::test_snapshot_shape_and_never_raises` | PASS |
|
||||
| TC-20 | GET /queue: блок serial_gate, существующие ключи не тронуты | `test_queue_endpoint::test_queue_has_serial_gate_block_and_keeps_existing_keys` + `::test_queue_serial_gate_reflects_freeze` | PASS |
|
||||
| TC-21 | STAGE_TRANSITIONS / QG_CHECKS не изменены | `test_serial_gate::test_registries_unchanged` | PASS |
|
||||
| TC-22 | миграция repo_freeze идемпотентна | `test_serial_gate_freeze::test_repo_freeze_migration_idempotent` | PASS |
|
||||
|
||||
Дополнительно покрыт kill-switch-путь среза ветки:
|
||||
`test_serial_gate_branch::test_branch_cut_immediate_when_kill_switch_off` — PASS.
|
||||
|
||||
**Покрытие тест-плана: 22/22 TC выполнены, все PASS.**
|
||||
|
||||
### Сопоставление с критериями приёмки `03-acceptance-criteria.md`
|
||||
| AC | Покрывающие TC | Результат |
|
||||
|----|----------------|-----------|
|
||||
| AC-1 (gate закрыт при активной) | TC-01 | PASS |
|
||||
| AC-2 (автостарт по done) | TC-04, TC-05 | PASS |
|
||||
| AC-3 (restart-safe) | TC-06, TC-07 | PASS |
|
||||
| AC-4 (per-repo) | TC-08, TC-09 | PASS |
|
||||
| AC-5 (rollback-freeze + алерт) | TC-10, TC-11, TC-12 | PASS |
|
||||
| AC-6 (нет stale-base) | TC-13, TC-14 | PASS |
|
||||
| AC-7 (kill-switch / нулевая регрессия) | TC-15, TC-16 | PASS |
|
||||
| AC-8 (fail-open claim) | TC-17 | PASS |
|
||||
| AC-9 (fail-closed freeze) | TC-18 | PASS |
|
||||
| AC-10 (наблюдаемость /queue) | TC-19, TC-20 | PASS |
|
||||
| AC-11 (инварианты неизменны) | TC-21, TC-22 | PASS |
|
||||
|
||||
## Smoke test API (prod 8500, read-only)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}`
|
||||
- `GET /status` → 200, отдаёт активные задачи (валидный JSON)
|
||||
- `GET /queue` → 200, валидный JSON; присутствуют блоки `reconcile`/`reaper`/
|
||||
`post_deploy`/`merge_verify`/`task_deps`/`recent`.
|
||||
|
||||
Примечание: блок `serial_gate` в ответе прод-`/queue` (8500) **отсутствует**, т.к. на
|
||||
проде сейчас работает код до ORCH-088 (фича ещё не задеплоена — это и есть тестируемая
|
||||
задача). Наличие и форма нового блока подтверждены интеграционным тестом TC-20 через
|
||||
TestClient на коде ветки. Деструктивных операций на прод-контейнере не выполнялось.
|
||||
|
||||
## Вывод pytest (хвост)
|
||||
```
|
||||
======================= 1114 passed, 1 warning in 31.52s =======================
|
||||
```
|
||||
Профильные сюиты:
|
||||
```
|
||||
======================== 24 passed, 1 warning in 1.39s =========================
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс зелёный (1114 passed), все 22 TC тест-плана выполнены и PASS,
|
||||
все 11 AC покрыты, smoke API OK. Задача готова к переходу на стадию `deploy-staging`.
|
||||
12
docs/work-items/ORCH-088/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-088/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-088
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
28
docs/work-items/ORCH-088/15-staging-log.md
Normal file
28
docs/work-items/ORCH-088/15-staging-log.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-09T08:23:42Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live staging environment
|
||||
(`orchestrator-staging`, 8501), run canonically inside the container
|
||||
(`scripts/staging_check.py --base-url http://localhost:8501 --mode stub`).
|
||||
|
||||
**Verdict: SUCCESS** (exit code 0).
|
||||
|
||||
Result: 8/10 checks PASS. All REAL (pipeline) checks are green:
|
||||
- Block A SMOKE: A1, A2, A3 — PASS
|
||||
- Block B ACCESS: B4, B5, B6 (registry isolation: sandbox present, prod ET/ORCH absent) — PASS
|
||||
- Block C E2E: C7 (create issue in SANDBOX), C8 (trigger pipeline) — PASS
|
||||
|
||||
The two failed checks are known sandbox-infra checks (depend on SANDBOX bot
|
||||
accounts being project members, not on the pipeline) and were waived per
|
||||
ORCH-061 (`staging_infra_tolerance_enabled=True`); the script still exited 0
|
||||
fail-closed because every REAL check is green.
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
7
docs/work-items/ORCH-089/00-business-request.md
Normal file
7
docs/work-items/ORCH-089/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Авто-режим по лейблам: autoApprove (орк сам подтверждает BRD) + autoDeploy (орк сам деплоит)
|
||||
|
||||
Work Item ID: ORCH-089
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
123
docs/work-items/ORCH-089/01-brd.md
Normal file
123
docs/work-items/ORCH-089/01-brd.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 01 — BRD: Авто-режим по лейблам (autoApprove + autoDeploy)
|
||||
|
||||
**Work Item:** ORCH-089
|
||||
**Repo:** orchestrator (self-hosting)
|
||||
**Стадия:** analysis
|
||||
**Приоритет:** Бэклог (запуск по решению Славы, serial e2e после ORCH-88)
|
||||
|
||||
> ⚠️ Прошлый подход (09.06) ОТМЕНЁН: «Стрим ревьюит и апрувит BRD» — НЕ реализовывать.
|
||||
> Актуальная модель: автономность управляется **лейблами Plane на задаче**, без участия людей.
|
||||
|
||||
## 1. Проблема / зачем
|
||||
|
||||
В конвейере два **человеческих** гейта — точки, где конвейер останавливается и ждёт
|
||||
ручного клика человека (Слава/Стрим):
|
||||
|
||||
1. **Гейт BRD** (стадия `analysis`): после завершения analyst задача переводится в
|
||||
`In Review` и ждёт, пока человек вручную выставит Plane-статус **Approved**, чтобы
|
||||
уйти на `architecture`.
|
||||
2. **Гейт деплоя** (стадия `deploy`): после зелёного staging задача переводится в
|
||||
`Awaiting Deploy` (Phase A, ORCH-036/059) и ждёт, пока человек вручную выставит
|
||||
статус **Confirm Deploy**, чтобы запустить прод-деплой (Phase B).
|
||||
|
||||
Для задач, которым **доверяем**, оба ручных решения избыточны и тормозят пакетный
|
||||
автономный прогон («10–20 задач за ночь», эпик ORCH-088). Нужно снять эти два
|
||||
человеческих решения **выборочно и декларативно** — через лейблы на конкретной задаче.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Дать оператору возможность пометить задачу лейблом и тем самым **разрешить орку
|
||||
самому пройти соответствующий человеческий гейт**, не трогая ни одну техническую
|
||||
проверку. Доверие выражается лейблом — на уровне отдельной задачи, обратимо, прозрачно.
|
||||
|
||||
## 3. Модель (решение Славы, 09.06)
|
||||
|
||||
| Лейбл на задаче | Эффект |
|
||||
|-----------------|--------|
|
||||
| `autoApprove` | Орк САМ подтверждает BRD (гейт 1: `In Review → Approved`), без человека. Конвейер идёт на `architecture`. |
|
||||
| `autoDeploy` | Орк САМ подтверждает прод-деплой (гейт 2: `Confirm Deploy`) и деплоит в прод после зелёного staging + всех тех-гейтов, без человека. |
|
||||
|
||||
**Лейблы независимы:**
|
||||
- только `autoApprove` → BRD авто, деплой вручную;
|
||||
- только `autoDeploy` → BRD вручную, деплой авто;
|
||||
- оба → полная автономность (анализ → деплой без единого ручного клика);
|
||||
- без лейблов → **текущее поведение** (оба гейта ручные, нулевая регрессия).
|
||||
|
||||
## 4. Критический инвариант — авто-режим снимает ТОЛЬКО человеческое решение
|
||||
|
||||
Авто-режим **не отключает и не ослабляет ни одну техническую проверку**. Все
|
||||
тех-гейты остаются на месте и блокируют при провале ровно как сейчас:
|
||||
|
||||
- `check_ci_green` (CI зелёный);
|
||||
- `check_staging_status` (staging healthy, ORCH-035);
|
||||
- security-гейт (gitleaks + pip-audit, ORCH-022);
|
||||
- merge-gate / re-test / merge-lease (ORCH-043);
|
||||
- image-freshness / provenance guard (ORCH-058);
|
||||
- merge-verify + regression-guard (ORCH-071/073);
|
||||
- post-deploy monitor (ORCH-021).
|
||||
|
||||
`autoDeploy` **никогда не деплоит сломанное** — он лишь заменяет ручной клик
|
||||
«Confirm Deploy» на авто-проход, и только когда все тех-гейты на ребре
|
||||
`deploy-staging → deploy` уже зелёные. `autoApprove` заменяет ручной клик «Approved»,
|
||||
но артефакты анализа (BRD/TRZ/AC/test-plan) должны существовать (`check_analysis_complete`).
|
||||
|
||||
## 5. Fail-safe (безопасность по умолчанию)
|
||||
|
||||
При любой неоднозначности — **откат к ручному гейту** (never auto):
|
||||
|
||||
- лейбл не распознан / Plane API недоступен / ошибка чтения лейблов;
|
||||
- неоднозначность сопоставления имени лейбла;
|
||||
- любое исключение в логике определения авто-режима.
|
||||
|
||||
Лучше подождать человека, чем авто-пропустить гейт по ошибке. Это согласуется с
|
||||
fail-closed-практикой проекта (ORCH-059 «нет статуса → нет деплоя»).
|
||||
|
||||
## 6. Прозрачность (обязательно)
|
||||
|
||||
Каждый авто-проход гейта **логируется и виден** оператору:
|
||||
|
||||
- запись в лог (кто/почему: `label autoApprove → auto-approved BRD` /
|
||||
`label autoDeploy → auto-confirmed prod deploy`);
|
||||
- Telegram-уведомление + строка/обновление в live-карточке задачи (ORCH-042/087);
|
||||
- Plane-коммент в задаче (как при ручном проходе гейта).
|
||||
|
||||
Слава должен по карточке/Telegram видеть, что задача прошла гейт автоматически (а не
|
||||
руками), и какой именно лейбл это разрешил.
|
||||
|
||||
## 7. Бизнес-требования (BR)
|
||||
|
||||
- **BR-1.** Лейбл `autoApprove` на задаче → BRD подтверждается автоматически
|
||||
(`In Review → Approved`) сразу после успешного analyst (артефакты готовы),
|
||||
конвейер идёт на `architecture`. Закрывается клок `brd_review_ended_at`.
|
||||
- **BR-2.** Лейбл `autoDeploy` на задаче → после зелёного staging и всех тех-гейтов
|
||||
прод-деплой (Phase B) триггерится автоматически, без ручного `Confirm Deploy`.
|
||||
- **BR-3.** Лейблы независимы; комбинация обоих даёт полную автономность анализ→деплой.
|
||||
- **BR-4.** Без лейблов поведение конвейера **не меняется** (оба гейта ручные).
|
||||
- **BR-5.** Тех-гейт красный → авто-режим НЕ проходит гейт; задача встаёт/заворачивается
|
||||
ровно как сейчас (авто-режим не маскирует провал тех-проверки).
|
||||
- **BR-6.** Нераспознанный/спорный лейбл / ошибка чтения → fail-safe к ручному гейту.
|
||||
- **BR-7.** Каждый авто-проход гейта логируется и виден в карточке/Telegram + Plane.
|
||||
- **BR-8.** Лейблы `autoApprove` и `autoDeploy` должны существовать в Plane-проекте ORCH
|
||||
(сейчас их нет — создать через labels API; инфра-предусловие).
|
||||
- **BR-9.** Раскат под kill-switch (как ORCH-035/043/059/088); выключенный флаг →
|
||||
строго прежнее поведение (нулевая регрессия для enduro-trails и для самого ORCH).
|
||||
- **BR-10.** Авто-проходы — только для self-hosting/applicable репо по тому же
|
||||
условному принципу, что и self-deploy (Phase A/B существуют только для self-hosting).
|
||||
Гейт BRD логически применим к любому репо, но раскат гейтится флагом/scope.
|
||||
|
||||
## 8. Вне scope (НЕ делаем в этой задаче)
|
||||
|
||||
- Любая логика «Стрим/человек ревьюит BRD» (отменённый подход).
|
||||
- Управление лейблами из UI оркестратора.
|
||||
- Авто-режим для REQUEST_CHANGES / откатов reviewer/tester (это не человеческие гейты —
|
||||
это технические вердикты, они и так автоматические).
|
||||
- Снятие/ослабление любого технического гейта.
|
||||
- Авто-снятие per-repo freeze (ORCH-088) — freeze остаётся ручным.
|
||||
|
||||
## 9. Допущения и зависимости
|
||||
|
||||
- Plane labels API v1 работает (`POST /labels/` подтверждён в бизнес-запросе; GET
|
||||
лейблов проекта и поле `labels` issue — проверить на этапе архитектуры/разработки).
|
||||
- Идёт поверх ORCH-088 (serial gate) — авто-режим совместим с serial e2e: serial-gate
|
||||
сериализует задачи, авто-режим убирает человеческие паузы внутри прохода одной задачи.
|
||||
- Self-deploy Phase A/B/C (ORCH-036/059/071) — точки врезки авто-деплоя.
|
||||
210
docs/work-items/ORCH-089/02-trz.md
Normal file
210
docs/work-items/ORCH-089/02-trz.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 02 — ТЗ: Авто-режим по лейблам (autoApprove + autoDeploy)
|
||||
|
||||
**Work Item:** ORCH-089
|
||||
**Базируется на BRD:** `01-brd.md`
|
||||
|
||||
> ТЗ фиксирует **что** должно измениться (модули, API, БД, гейты, артефакты,
|
||||
> флаги) и предметные требования к поведению. Архитектурное **как** (структура
|
||||
> leaf-модуля, стратегия кэша лейблов, точная сигнатура хелперов) — за архитектором
|
||||
> (ADR `06-adr/`). ТЗ задаёт границы, которые архитектура обязана соблюсти.
|
||||
|
||||
---
|
||||
|
||||
## 1. Обзор изменения
|
||||
|
||||
Ввести два независимых авто-прохода человеческих гейтов, управляемых лейблами Plane
|
||||
на конкретной задаче:
|
||||
|
||||
- **autoApprove** — авто-проход гейта BRD (`analysis`: `In Review → Approved`).
|
||||
- **autoDeploy** — авто-проход гейта прод-деплоя (`deploy`: `Confirm Deploy` → Phase B).
|
||||
|
||||
Принцип врезки — аддитивно, по образцу условных под-гейтов (ORCH-035/043/058/088):
|
||||
leaf-модуль чистой логики (never-raise) + точечные врезки в существующие точки
|
||||
принятия решений + флаги в `config.py`. **`STAGE_TRANSITIONS` и реестр `QG_CHECKS`
|
||||
НЕ трогаются** — авто-режим переиспользует уже существующие переходы и гейты, лишь
|
||||
устраняя ожидание человеческого сигнала.
|
||||
|
||||
---
|
||||
|
||||
## 2. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль изменения |
|
||||
|--------|----------------|
|
||||
| `src/labels.py` (**новый, leaf**) | Чистая логика авто-режима: `auto_approve_applies(repo)`, `auto_deploy_applies(repo)`, `has_label(work_item_id, label, project_id) -> bool/None`, нормализация имён лейблов, fail-safe. never-raise. |
|
||||
| `src/plane_sync.py` | Новая функция чтения лейблов issue из Plane API (`fetch_issue_labels`) + резолв карты лейблов проекта (имя↔uuid, с кэшем по образцу `get_project_states`). Новый сеттер статуса `set_issue_approved` (PATCH в Approved-UUID) для индикации авто-аппрува. |
|
||||
| `src/stage_engine.py` | Врезка autoApprove в `_handle_analysis_approved_flow` (ветка `files_ok`, после `set_issue_in_review`). Врезка autoDeploy в `_handle_self_deploy_phase_a` (после advance на `deploy`, перед возвратом). |
|
||||
| `src/config.py` | Новые флаги `auto_label_enabled`, `auto_approve_label`, `auto_deploy_label`, `auto_label_repos` (+ при необходимости TTL кэша лейблов). |
|
||||
| `src/main.py` (`GET /queue`) | Аддитивный блок наблюдаемости `auto_labels` (опционально: счётчики авто-проходов). |
|
||||
| `src/webhooks/plane.py` | (Опц.) если payload вебхука несёт `labels` — использовать как быстрый путь; иначе чтение через `fetch_issue_labels`. Источник истины лейблов — Plane API (надёжнее payload). |
|
||||
|
||||
> Точные имена функций/флагов — ориентир; финальные сигнатуры закрепляет ADR.
|
||||
> Обязательное требование: вся логика определения авто-режима — **never-raise** и
|
||||
> при ошибке возвращает «нет авто» (fail-safe к ручному гейту, BR-6).
|
||||
|
||||
---
|
||||
|
||||
## 3. Точки врезки (insertion points) — предметные требования
|
||||
|
||||
### 3.1 Гейт BRD (autoApprove)
|
||||
|
||||
**Текущее поведение** (`src/stage_engine.py::_handle_analysis_approved_flow`, ветка
|
||||
`files_ok`, ~стр. 584–599):
|
||||
1. `set_issue_in_review(work_item_id)`;
|
||||
2. Plane-коммент «артефакты готовы»;
|
||||
3. `notify_approve_requested(task_id)`;
|
||||
4. `return` — **без advance**; ждёт ручного Approved через webhook
|
||||
(`handle_verdict(approved=True)` → `_try_advance_stage` → advance на `architecture`).
|
||||
|
||||
**Требуемое поведение при `autoApprove`:**
|
||||
- ПОСЛЕ установки `In Review` и коммента (для прозрачности и клока) проверить лейбл
|
||||
`autoApprove` на задаче;
|
||||
- если лейбл есть И `auto_approve_applies(repo)` И `auto_label_enabled`:
|
||||
- выставить Plane-статус **Approved** (индикация; `set_issue_approved`);
|
||||
- залогировать авто-проход (`label autoApprove → BRD auto-approved`);
|
||||
- отправить Telegram + Plane-коммент о факте авто-аппрува (BR-7, прозрачность);
|
||||
- инициировать тот же advance, что делает ручной Approved, т.е. переход
|
||||
`analysis → architecture` через штатный путь (`advance_stage(..., finished_agent=None)`
|
||||
с `qg_passed`/`approved-via-status`-семантикой), чтобы:
|
||||
- закрылся клок `brd_review_ended_at` (`mark_brd_review_ended`),
|
||||
- выполнились все стандартные пост-переходные эффекты (карточка, plane-sync);
|
||||
- если лейбла нет / ошибка чтения → **прежнее поведение** (return, ждём человека).
|
||||
|
||||
> Требование к реализации advance: НЕ дублировать переходную логику. Авто-аппрув
|
||||
> обязан идти через тот же advance-путь, что и человеческий Approved (единый источник
|
||||
> истины перехода). Защита от двойного advance/гонки с реальным webhook — идемпотентность
|
||||
> (advance применяется один раз; повторный сигнал — no-op).
|
||||
|
||||
### 3.2 Гейт прод-деплоя (autoDeploy)
|
||||
|
||||
**Текущее поведение** (`src/stage_engine.py::_handle_self_deploy_phase_a`, ~стр. 1151):
|
||||
вызывается на ребре `deploy-staging → deploy` ПОСЛЕ зелёных под-гейтов (security →
|
||||
merge-gate → image-freshness → staging). Делает:
|
||||
1. `update_task_stage(task_id, "deploy")` + `notify_stage_change`;
|
||||
2. `set_issue_awaiting_deploy`;
|
||||
3. `write_marker(APPROVE_REQUESTED)`;
|
||||
4. Plane-коммент + Telegram «смените статус на Confirm Deploy»;
|
||||
5. `return` — ждёт ручного `Confirm Deploy` → `handle_confirm_deploy` →
|
||||
`advance_stage(confirm_deploy=True)` → `_handle_self_deploy_phase_b` (initiate_deploy).
|
||||
|
||||
**Требуемое поведение при `autoDeploy`:**
|
||||
- Все тех-гейты ребра `deploy-staging → deploy` уже зелёные к моменту Phase A
|
||||
(иначе сюда не дошли бы) — это структурно гарантирует BR-5 (авто не деплоит сломанное);
|
||||
- ПОСЛЕ advance на `deploy` (шаг 1) проверить лейбл `autoDeploy`;
|
||||
- если лейбл есть И `auto_deploy_applies(repo)` И `auto_label_enabled`:
|
||||
- залогировать авто-проход (`label autoDeploy → prod deploy auto-confirmed`);
|
||||
- Telegram + Plane-коммент о факте авто-деплоя (BR-7);
|
||||
- инициировать Phase B тем же путём, что ручной Confirm Deploy
|
||||
(`_handle_self_deploy_phase_b(...)`), сохранив идемпотентность (маркер `INITIATED`);
|
||||
- индикация статуса — `Deploying` (ставит уже сам Phase B);
|
||||
- если лейбла нет / ошибка → **прежнее поведение** (Phase A ждёт человека).
|
||||
|
||||
> Требование: НЕ обходить и НЕ дублировать тех-гейты. autoDeploy запускается строго
|
||||
> в точке, где Phase A уже прошёл все под-гейты. Phase C (finalizer) + merge-verify +
|
||||
> regression-guard + post-deploy monitor остаются неизменны и продолжают верифицировать
|
||||
> результат деплоя.
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения Plane API
|
||||
|
||||
Новых endpoint оркестратора (FastAPI) — **нет**. Изменяется только клиентское
|
||||
взаимодействие с Plane API v1:
|
||||
|
||||
| Действие | Endpoint Plane | Назначение |
|
||||
|----------|----------------|------------|
|
||||
| Чтение лейблов issue | `GET /workspaces/{slug}/projects/{pid}/issues/{issue_id}/` → поле `labels` (список uuid) | Узнать, какие лейблы навешены на задачу. |
|
||||
| Карта лейблов проекта | `GET /workspaces/{slug}/projects/{pid}/labels/` → `[{id,name}]` | Сопоставить uuid лейбла ↔ имя (`autoApprove`/`autoDeploy`). Кэшировать (TTL, образец `get_project_states`/`plane_states_ttl_s`). |
|
||||
| Установка Approved | `PATCH /…/issues/{issue_id}/` `{"state": <approved_uuid>}` | Индикация авто-аппрува BRD (`set_issue_approved`, через `get_project_states(...)["approved"]`). |
|
||||
| (Инфра) создание лейблов | `POST /…/labels/` | Однократно создать `autoApprove` и `autoDeploy` в проекте ORCH (см. `07-infra-requirements.md`). |
|
||||
|
||||
**Требования:**
|
||||
- Все GET/PATCH к Plane — через существующие `PLANE_HEADERS`/`_resolve_project_id`,
|
||||
таймаут как у соседей (10с), never-raise.
|
||||
- Сопоставление лейбла — по **имени** (нормализованному: регистр/пробелы), резолвенному
|
||||
из карты лейблов проекта; неоднозначность/нет совпадения → «нет лейбла» (fail-safe).
|
||||
- Чтение лейблов НЕ должно блокировать конвейер при недоступности Plane: ошибка →
|
||||
«нет авто» → ручной гейт.
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
**Не требуются.** Авто-режим — stateless относительно БД:
|
||||
- источник истины лейблов — Plane (читается на гейте);
|
||||
- идемпотентность авто-деплоя обеспечена существующими sentinel-маркерами
|
||||
(`APPROVE_REQUESTED`/`INITIATED`, ORCH-036), а не новой колонкой;
|
||||
- клок `brd_review_*` уже существует (ORCH-087).
|
||||
|
||||
Если архитектура решит кэшировать факт авто-прохода для наблюдаемости — допускается
|
||||
**аддитивная** идемпотентная миграция (`_ensure_column`, образец ORCH-065 `jobs.pid`),
|
||||
но это не требование ТЗ (предпочтительно без миграции, restart-safe через Plane/маркеры).
|
||||
|
||||
---
|
||||
|
||||
## 6. Новые QG checks
|
||||
|
||||
**Не вводятся.** Авто-режим не добавляет проверок качества — он устраняет ожидание
|
||||
человеческого сигнала на существующих гейтах. Реестр `QG_CHECKS` и
|
||||
`check_analysis_approved` / `check_deploy_status` / `check_staging_status` —
|
||||
**без изменений**. (Это сознательно: добавление QG-чека усложнило бы матрицу и нарушило
|
||||
инвариант «STAGE_TRANSITIONS/QG_CHECKS не трогаются», характерный для соседних под-гейтов.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Конфигурация (флаги `src/config.py`)
|
||||
|
||||
По образцу ORCH-035/043/059/088 (kill-switch + CSV scope):
|
||||
|
||||
| Флаг | Тип / дефолт | Назначение |
|
||||
|------|--------------|------------|
|
||||
| `auto_label_enabled` | `bool = True` (env `ORCH_AUTO_LABEL_ENABLED`) | Глобальный kill-switch обоих авто-режимов. `False` → строго прежнее поведение (оба гейта ручные). |
|
||||
| `auto_approve_label` | `str = "autoApprove"` | Имя лейбла гейта BRD. |
|
||||
| `auto_deploy_label` | `str = "autoDeploy"` | Имя лейбла гейта деплоя. |
|
||||
| `auto_label_repos` | `str = ""` (CSV) | Scope. Пусто → self-hosting only (как ORCH-035/043), либо «все репо» — выбор фиксирует ADR; дефолт безопасный (self-hosting). |
|
||||
| `auto_label_states_ttl_s` | `int` (опц.) | TTL кэша карты лейблов проекта (образец `plane_states_ttl_s`). |
|
||||
|
||||
**Требование:** при `auto_label_enabled=False` — нулевая регрессия (ни одного нового
|
||||
сетевого вызова на гейтах, поведение 1:1 как до ORCH-089).
|
||||
|
||||
---
|
||||
|
||||
## 8. Наблюдаемость
|
||||
|
||||
- Каждый авто-проход → `logger.info` с причиной (label X → действие).
|
||||
- Telegram-уведомление + обновление live-карточки (ORCH-042/087, never-raise).
|
||||
- Plane-коммент в задаче (автор — `analyst` для BRD, `deployer` для деплоя — по образцу
|
||||
существующих комментов гейтов).
|
||||
- (Опц.) аддитивный блок `auto_labels` в `GET /queue` (enabled, label-имена, scope,
|
||||
счётчики `auto_approved_total`/`auto_deployed_total`) — образец блоков
|
||||
`reconcile`/`serial_gate`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Артефакты pipeline
|
||||
|
||||
Новых обязательных артефактов задачи **нет**. Авто-проходы отражаются в:
|
||||
- Plane-комментах и Telegram/карточке (прозрачность, BR-7);
|
||||
- существующих логах деплоя (`14-deploy-log.md` для autoDeploy — пишется Phase C как сейчас).
|
||||
|
||||
Документация golden-source (обязательно в этом же PR):
|
||||
- `CLAUDE.md` — раздел про авто-режим по лейблам (флаги, инвариант «снимает только
|
||||
человеческое решение»);
|
||||
- `docs/architecture/README.md` — описание врезок autoApprove/autoDeploy + флаги;
|
||||
- `06-adr/ADR-001-*.md` — архитектурное решение (точки врезки, fail-safe, чтение лейблов);
|
||||
- `07-infra-requirements.md` — создание лейблов `autoApprove`/`autoDeploy` в Plane ORCH;
|
||||
- `CHANGELOG.md` — `## [Unreleased]`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Инварианты (что НЕ должно измениться)
|
||||
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_analysis_approved`,
|
||||
`check_deploy_status`, `check_staging_status` — без изменений.
|
||||
- Все технические под-гейты (security/merge-gate/image-freshness/merge-verify/
|
||||
regression-guard/post-deploy) — без изменений; авто-режим их не обходит.
|
||||
- Ручной путь (без лейблов) — 1:1 как сейчас.
|
||||
- Схема БД, exit-коды deploy-хука, merge-lease, sentinel-маркеры self-deploy — без изменений.
|
||||
- never-raise: ни одна ошибка авто-режима не роняет конвейер и не пропускает гейт
|
||||
ошибочно (fail-safe к ручному).
|
||||
- Self-hosting: авто-режим НЕ рестартит/не роняет прод вне штатного Phase B (который
|
||||
и так есть); autoDeploy лишь авто-инициирует существующий путь деплоя.
|
||||
153
docs/work-items/ORCH-089/03-acceptance-criteria.md
Normal file
153
docs/work-items/ORCH-089/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 03 — Критерии приёмки: Авто-режим по лейблам (ORCH-089)
|
||||
|
||||
Каждый критерий — чёткое условие PASS/FAIL. Маппинг на BR (`01-brd.md`) и AC
|
||||
бизнес-запроса указан в скобках.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — autoApprove проходит гейт BRD (BR-1 / BizAC-1)
|
||||
|
||||
**Дано:** задача с лейблом `autoApprove`, analyst успешно завершился (артефакты
|
||||
BRD/TRZ/AC/test-plan на месте), `auto_label_enabled=True`, репо в scope.
|
||||
**Когда:** срабатывает `_handle_analysis_approved_flow` (ветка `files_ok`).
|
||||
**Тогда:**
|
||||
- задача автоматически переходит `analysis → architecture` без человеческого Approved;
|
||||
- Plane-статус выставлен в `Approved` (индикация);
|
||||
- клок `brd_review_ended_at` закрыт (`mark_brd_review_ended`);
|
||||
- авто-проход залогирован + Telegram/карточка/Plane-коммент уведомляют о факте.
|
||||
|
||||
**PASS:** стадия задачи стала `architecture` без внешнего webhook Approved; клок закрыт.
|
||||
**FAIL:** задача осталась в `In Review`/`analysis` ИЛИ advance прошёл без индикации/лога.
|
||||
|
||||
---
|
||||
|
||||
## AC-2 — autoDeploy триггерит прод-деплой (BR-2 / BizAC-2)
|
||||
|
||||
**Дано:** задача с лейблом `autoDeploy` дошла до ребра `deploy-staging → deploy`,
|
||||
все тех-гейты (security, merge-gate, image-freshness, staging) зелёные, Phase A advance
|
||||
на `deploy` выполнен, `auto_label_enabled=True`, репо в scope (self-hosting).
|
||||
**Когда:** срабатывает `_handle_self_deploy_phase_a`.
|
||||
**Тогда:**
|
||||
- Phase B (`_handle_self_deploy_phase_b`) инициируется автоматически, без ручного
|
||||
`Confirm Deploy`;
|
||||
- маркер `INITIATED` выставлен (идемпотентность), finalizer-job (Phase C) поставлен;
|
||||
- Plane-статус → `Deploying`; авто-проход залогирован + Telegram/Plane-коммент.
|
||||
|
||||
**PASS:** прод-деплой инициирован без статуса Confirm Deploy от человека; Phase C армлен.
|
||||
**FAIL:** задача застряла в `Awaiting Deploy`, ожидая ручного Confirm Deploy.
|
||||
|
||||
---
|
||||
|
||||
## AC-3 — оба лейбла → полная автономность (BR-3 / BizAC-3)
|
||||
|
||||
**Дано:** задача с лейблами `autoApprove` И `autoDeploy`, все тех-гейты по пути зелёные.
|
||||
**Когда:** задача проходит конвейер `analysis → … → deploy`.
|
||||
**Тогда:** задача проходит от анализа до прод-деплоя без единого ручного клика
|
||||
(ни Approved, ни Confirm Deploy).
|
||||
|
||||
**PASS:** ноль человеческих гейт-кликов; задача достигла `deploy`/`done` автономно.
|
||||
**FAIL:** конвейер остановился хотя бы на одном из двух человеческих гейтов.
|
||||
|
||||
---
|
||||
|
||||
## AC-4 — без лейблов поведение НЕ меняется (BR-4 / BizAC-4)
|
||||
|
||||
**Дано:** задача без лейблов `autoApprove`/`autoDeploy`.
|
||||
**Когда:** проходит гейты BRD и деплоя.
|
||||
**Тогда:** оба гейта остаются ручными — задача ждёт `In Review → Approved` (человек) и
|
||||
`Awaiting Deploy → Confirm Deploy` (человек), ровно как до ORCH-089.
|
||||
|
||||
**PASS:** на гейте BRD задача в `In Review` ждёт человека; на гейте деплоя — в
|
||||
`Awaiting Deploy` ждёт человека. Нулевая регрессия.
|
||||
**FAIL:** задача без лейблов авто-прошла любой гейт.
|
||||
|
||||
---
|
||||
|
||||
## AC-5 — красный тех-гейт блокирует авто-режим (BR-5 / BizAC-5)
|
||||
|
||||
**Дано:** задача с лейблом `autoDeploy`, но один из тех-гейтов на ребре
|
||||
`deploy-staging → deploy` красный (например, staging unhealthy / merge-gate конфликт /
|
||||
security FAIL / image-freshness mismatch).
|
||||
**Когда:** конвейер достигает ребра деплоя.
|
||||
**Тогда:** Phase A НЕ достигается (под-гейт завернул задачу) → autoDeploy НЕ
|
||||
инициирует Phase B; задача откатывается/встаёт ровно как при ручном режиме.
|
||||
|
||||
**PASS:** при красном тех-гейте прод-деплой НЕ инициирован, несмотря на лейбл; поведение
|
||||
тождественно ручному режиму при том же провале.
|
||||
**FAIL:** autoDeploy инициировал прод-деплой при красном тех-гейте.
|
||||
|
||||
> Аналогично для autoApprove: при отсутствии артефактов (`check_analysis_complete` FAIL)
|
||||
> авто-аппрув не срабатывает (нет advance), задача не уходит на architecture.
|
||||
|
||||
---
|
||||
|
||||
## AC-6 — fail-safe к ручному гейту (BR-6 / BizAC-6)
|
||||
|
||||
**Дано:** одно из: лейбл не распознан; Plane API недоступен при чтении лейблов;
|
||||
неоднозначное сопоставление имени; исключение в логике авто-режима.
|
||||
**Когда:** гейт BRD или деплоя.
|
||||
**Тогда:** авто-режим НЕ срабатывает → откат к ручному гейту (задача ждёт человека);
|
||||
конвейер НЕ падает.
|
||||
|
||||
**PASS:** при ошибке/неоднозначности задача переходит в ручное ожидание (никогда не
|
||||
авто-проход по ошибке); ни одно исключение не всплывает наружу (never-raise).
|
||||
**FAIL:** ошибка чтения лейблов привела к авто-проходу ИЛИ к падению/застреванию конвейера.
|
||||
|
||||
---
|
||||
|
||||
## AC-7 — прозрачность каждого авто-прохода (BR-7 / BizAC-7)
|
||||
|
||||
**Дано:** любой сработавший авто-проход (autoApprove или autoDeploy).
|
||||
**Когда:** гейт пройден автоматически.
|
||||
**Тогда:** факт виден в: (а) логе с причиной (label X → действие); (б) Telegram +
|
||||
live-карточке задачи; (в) Plane-комменте.
|
||||
|
||||
**PASS:** все три канала несут отметку об авто-проходе и о том, какой лейбл его разрешил.
|
||||
**FAIL:** авто-проход произошёл «молча» (нет отметки хотя бы в одном из обязательных
|
||||
каналов: лог + Telegram/карточка + Plane).
|
||||
|
||||
---
|
||||
|
||||
## AC-8 — kill-switch и scope (BR-9 / BR-10)
|
||||
|
||||
**Дано:** `auto_label_enabled=False` (или репо вне `auto_label_repos`).
|
||||
**Когда:** задача с лейблами проходит гейты.
|
||||
**Тогда:** авто-режим полностью отключён — оба гейта ручные, никаких новых сетевых
|
||||
вызовов на гейтах; поведение 1:1 как до ORCH-089 (включая нулевую регрессию для enduro).
|
||||
|
||||
**PASS:** при выключенном флаге лейблы игнорируются, поведение прежнее.
|
||||
**FAIL:** при `False` авто-режим сработал ИЛИ появилась регрессия для не-scope репо.
|
||||
|
||||
---
|
||||
|
||||
## AC-9 — независимость лейблов (BR-3)
|
||||
|
||||
**Дано:** задача только с `autoApprove` (без `autoDeploy`) — и симметрично наоборот.
|
||||
**Тогда:**
|
||||
- только `autoApprove`: BRD авто-проходит, деплой ждёт ручного Confirm Deploy;
|
||||
- только `autoDeploy`: BRD ждёт ручного Approved, деплой авто-проходит.
|
||||
|
||||
**PASS:** каждый лейбл влияет строго на свой гейт, второй гейт остаётся ручным.
|
||||
**FAIL:** один лейбл повлиял на оба гейта.
|
||||
|
||||
---
|
||||
|
||||
## AC-10 — инварианты неизменны (TRZ §10)
|
||||
|
||||
**Тогда:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_analysis_approved`,
|
||||
`check_deploy_status`, `check_staging_status`, схема БД, все технические под-гейты,
|
||||
sentinel-маркеры self-deploy, exit-коды deploy-хука — **не изменены**.
|
||||
|
||||
**PASS:** diff не затрагивает перечисленные контракты; существующие тесты этих
|
||||
компонентов зелёные.
|
||||
**FAIL:** любой из инвариантных контрактов изменён.
|
||||
|
||||
---
|
||||
|
||||
## AC-11 — документация обновлена (CLAUDE.md §правила 2/6)
|
||||
|
||||
**Тогда:** в том же PR обновлены `CLAUDE.md`, `docs/architecture/README.md`,
|
||||
заведён `06-adr/ADR-001-*`, `07-infra-requirements.md` (создание лейблов), `CHANGELOG.md`.
|
||||
|
||||
**PASS:** документация-golden-source синхронна с кодом.
|
||||
**FAIL:** функционал изменён, документация — нет (reviewer → REQUEST_CHANGES).
|
||||
172
docs/work-items/ORCH-089/04-test-plan.yaml
Normal file
172
docs/work-items/ORCH-089/04-test-plan.yaml
Normal file
@@ -0,0 +1,172 @@
|
||||
work_item: ORCH-089
|
||||
title: "Авто-режим по лейблам: autoApprove (авто-BRD) + autoDeploy (авто-деплой)"
|
||||
description: >
|
||||
План тестов авто-прохода двух человеческих гейтов по лейблам Plane.
|
||||
Фокус юнит-тестов — чистая логика src/labels.py (never-raise, fail-safe) и
|
||||
врезки в stage_engine (autoApprove в _handle_analysis_approved_flow,
|
||||
autoDeploy в _handle_self_deploy_phase_a). Сеть Plane — мокается.
|
||||
Инвариант: STAGE_TRANSITIONS/QG_CHECKS/тех-гейты не трогаются.
|
||||
|
||||
tests:
|
||||
# --- src/labels.py: чистая логика авто-режима (never-raise, fail-safe) -----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "has_label возвращает True, когда лейбл присутствует на issue (мок Plane labels)"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "has_label возвращает False, когда лейбла нет на issue"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "has_label при ошибке Plane API / таймауте → fail-safe (нет авто, never-raise)"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "Сопоставление имени лейбла нормализовано (регистр/пробелы); неоднозначность → нет авто"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "auto_approve_applies/auto_deploy_applies: scope CSV + self-hosting; пустой scope по дефолту"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "auto_label_enabled=False → has_label/applies дают 'нет авто' без сетевых вызовов"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
# --- plane_sync: чтение лейблов + сеттер Approved ---------------------------
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "fetch_issue_labels парсит поле labels issue и резолвит uuid→имя по карте проекта (мок httpx)"
|
||||
module: tests/test_plane_sync_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Карта лейблов проекта кэшируется с TTL (повтор в окне TTL не делает второй GET)"
|
||||
module: tests/test_plane_sync_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "set_issue_approved PATCHит issue в Approved-UUID (get_project_states['approved']); never-raise при ошибке"
|
||||
module: tests/test_plane_sync_labels.py
|
||||
expected: PASS
|
||||
|
||||
# --- autoApprove: врезка в _handle_analysis_approved_flow ------------------
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "autoApprove + артефакты готовы → авто-advance analysis→architecture, Approved выставлен, клок brd_review_ended закрыт"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Без лейбла autoApprove → прежнее поведение: In Review, return без advance (ждёт человека)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "autoApprove, но артефактов нет (check_analysis_complete FAIL) → НЕ advance (AC-5 для BRD)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "autoApprove идёт через тот же advance-путь, что ручной Approved (нет дублирования логики; идемпотентно при повторе)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "autoApprove: авто-проход логируется + Telegram/карточка/Plane-коммент вызваны (прозрачность AC-7)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
# --- autoDeploy: врезка в _handle_self_deploy_phase_a ----------------------
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "autoDeploy + Phase A advance на deploy → автоматически вызывается _handle_self_deploy_phase_b (initiate_deploy)"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "Без лейбла autoDeploy → прежнее поведение: Awaiting Deploy, ждёт ручного Confirm Deploy"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "autoDeploy идемпотентен: маркер INITIATED уже стоит → повторный авто-триггер = no-op"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "autoDeploy не-self репо / вне scope → no-op (Phase A/B существуют только для self-hosting)"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: "autoDeploy: авто-проход логируется + Telegram + Plane-коммент (прозрачность AC-7)"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
# --- независимость лейблов + kill-switch -----------------------------------
|
||||
- id: TC-20
|
||||
type: unit
|
||||
description: "Только autoApprove (без autoDeploy): BRD авто, деплой ждёт человека (AC-9)"
|
||||
module: tests/test_auto_label_combinations.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "Только autoDeploy (без autoApprove): BRD ждёт человека, деплой авто (AC-9)"
|
||||
module: tests/test_auto_label_combinations.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "auto_label_enabled=False → оба гейта ручные при наличии обоих лейблов (kill-switch AC-8)"
|
||||
module: tests/test_auto_label_combinations.py
|
||||
expected: PASS
|
||||
|
||||
# --- интеграция: сквозной авто-проход на ребрах конвейера ------------------
|
||||
- id: TC-23
|
||||
type: integration
|
||||
description: "Оба лейбла + все тех-гейты зелёные → задача проходит analysis→deploy без ручных кликов (AC-3)"
|
||||
module: tests/test_auto_labels_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-24
|
||||
type: integration
|
||||
description: "autoDeploy + красный staging/merge-gate → Phase A не достигнут, Phase B не инициирован (AC-5)"
|
||||
module: tests/test_auto_labels_integration.py
|
||||
expected: PASS
|
||||
|
||||
# --- инварианты / регрессия ------------------------------------------------
|
||||
- id: TC-25
|
||||
type: integration
|
||||
description: "Регресс: задача без лейблов проходит оба гейта ровно как до ORCH-089 (AC-4)"
|
||||
module: tests/test_auto_labels_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-26
|
||||
type: unit
|
||||
description: "Инвариант: STAGE_TRANSITIONS и реестр QG_CHECKS не изменены ORCH-089 (snapshot-сверка)"
|
||||
module: tests/test_auto_labels_invariants.py
|
||||
expected: PASS
|
||||
220
docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md
Normal file
220
docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# ADR-001: Авто-режим по лейблам — autoApprove (гейт BRD) + autoDeploy (гейт прод-деплоя)
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
В конвейере два **человеческих** гейта (точки, где конвейер останавливается и ждёт
|
||||
ручного клика человека):
|
||||
|
||||
1. **Гейт BRD** (`analysis`): после успешного analyst задача переводится в `In Review`
|
||||
(`_handle_analysis_approved_flow`, ветка `files_ok`) и ждёт ручного Plane-статуса
|
||||
**Approved**. Approved прилетает вебхуком → `handle_verdict(approved=True)` →
|
||||
`_try_advance_stage` → `advance_stage(..., finished_agent=None)` (ветка
|
||||
`check_analysis_approved` / `approved-via-status`) → advance `analysis → architecture`
|
||||
+ `mark_brd_review_ended`.
|
||||
2. **Гейт прод-деплоя** (`deploy`): на ребре `deploy-staging → deploy` после зелёных
|
||||
под-гейтов (security → merge-gate → image-freshness → staging) выполняется Phase A
|
||||
(`_handle_self_deploy_phase_a`): advance на `deploy` + `Awaiting Deploy` + маркер
|
||||
`APPROVE_REQUESTED` + просьба сменить статус на **Confirm Deploy**. Confirm Deploy
|
||||
прилетает вебхуком → `handle_confirm_deploy` → `advance_stage(..., confirm_deploy=True)`
|
||||
→ `_handle_self_deploy_phase_b` (`initiate_deploy` + маркер `INITIATED` + finalizer).
|
||||
|
||||
Для задач, которым **доверяем** (пакетный автономный прогон, эпик ORCH-088), оба ручных
|
||||
клика избыточны и тормозят прогон «10–20 задач за ночь». Нужно снять **только эти два
|
||||
человеческих решения** — выборочно (на уровне отдельной задачи), декларативно (лейблом
|
||||
Plane), обратимо, прозрачно и **не трогая ни одной технической проверки** (BRD §4).
|
||||
|
||||
Прошлый подход «Стрим ревьюит и апрувит BRD» (09.06) ОТМЕНЁН. Актуальная модель —
|
||||
лейблы на задаче (`autoApprove`, `autoDeploy`), независимые, без участия людей.
|
||||
|
||||
## Решение
|
||||
|
||||
Аддитивная врезка по образцу условных под-гейтов проекта (ORCH-035/043/058/059/088):
|
||||
**leaf-модуль чистой логики (never-raise) + точечные врезки в существующие точки принятия
|
||||
решений + флаги в `config.py`**. `STAGE_TRANSITIONS`, реестр `QG_CHECKS` и все `check_*`
|
||||
**НЕ трогаются** — авто-режим переиспользует уже существующие переходы и гейты, лишь
|
||||
устраняя ожидание человеческого сигнала.
|
||||
|
||||
### D1. Новый leaf-модуль `src/labels.py` (чистая логика, never-raise)
|
||||
|
||||
Контракт «никогда не падает; при любой ошибке/неоднозначности → "нет авто"»
|
||||
(fail-safe к ручному гейту, BR-6/AC-6). Публичная поверхность:
|
||||
|
||||
| Функция | Контракт |
|
||||
|---------|----------|
|
||||
| `auto_approve_applies(repo) -> bool` | scope autoApprove (см. D5). False при kill-switch/ошибке. |
|
||||
| `auto_deploy_applies(repo) -> bool` | scope autoDeploy (см. D5). False при kill-switch/ошибке. |
|
||||
| `has_label(work_item_id, label_name, project_id=None) -> bool` | True ⇔ на issue навешен лейбл с именем `label_name` (нормализованным). **Любая** ошибка/неоднозначность/недоступность Plane → **False**. |
|
||||
| `snapshot() -> dict` | read-only для `GET /queue` (enabled, имена лейблов, scope). never-raise. |
|
||||
|
||||
`has_label` резолвит так (всё внутри одного `try/except → False`):
|
||||
1. `labels = plane_sync.fetch_issue_labels(work_item_id, project_id)` — список uuid
|
||||
лейблов issue (None при ошибке → `has_label=False`);
|
||||
2. `name_map = plane_sync.get_project_labels(project_id)` — `{normalized_name → uuid}`
|
||||
карта лейблов проекта (кэш с TTL, см. D4);
|
||||
3. нормализация искомого имени (`_normalize`: `strip().casefold()`);
|
||||
4. `target_uuid = name_map.get(normalized)`; если нет совпадения **или** имя
|
||||
неоднозначно (две записи проекта свелись к одному нормализованному имени) →
|
||||
**False** (fail-safe);
|
||||
5. `return target_uuid in set(labels)`.
|
||||
|
||||
> Источник истины лейблов — **Plane API**, не payload вебхука: обе точки врезки —
|
||||
> launcher-path (analyst-finished / staging-deployer-finished), где payload недоступен;
|
||||
> API надёжнее и единообразен. (Подтверждено: `src/webhooks/plane.py` не несёт `labels`.)
|
||||
|
||||
### D2. Чтение лейблов в `src/plane_sync.py`
|
||||
|
||||
- `fetch_issue_labels(work_item_id, project_id=None) -> list[str] | None` —
|
||||
`GET …/issues/{issue_id}/` → поле `labels` (список uuid). Через
|
||||
`_resolve_project_id` + `find_issue_id` + `PLANE_HEADERS`, таймаут 10с (как соседи).
|
||||
Ошибка/issue-not-found → `None` (отличимо от пустого списка `[]` = «лейблов нет»).
|
||||
- `get_project_labels(project_id) -> dict[str,str]` —
|
||||
`GET …/projects/{pid}/labels/` → `{normalized_name → uuid}`. **Кэш по образцу
|
||||
`get_project_states`** (`_LABELS_CACHE` per-project + TTL `_cache_record_fresh`),
|
||||
чтобы не бить API на каждом гейте. Стейл-кэш при сетевой ошибке отдаётся как у
|
||||
`get_project_states` (safer-than-empty). Пустой результат / ошибка без кэша → `{}`
|
||||
→ `has_label=False`.
|
||||
- `set_issue_approved(work_item_id, project_id=None)` — новый сеттер, 1:1 калька
|
||||
`set_issue_in_review`: `state_id = get_project_states(pid)["approved"]` →
|
||||
`_set_issue_state_direct`. Ключ `approved` уже существует в `_DEFAULT_STATES`
|
||||
и `_PLANE_NAME_TO_KEY` (`"Approved" → "approved"`), отдельная инфра-настройка не нужна.
|
||||
|
||||
### D3. Врезка autoApprove — `_handle_analysis_approved_flow`, ветка `files_ok`
|
||||
|
||||
После существующих шагов (`set_issue_in_review` + analyst-коммент + `notify_approve_requested`
|
||||
— оставлены ради клока/прозрачности/симметрии с ручным путём), ДО `return`:
|
||||
|
||||
```
|
||||
if labels.auto_approve_applies(repo) and labels.has_label(work_item_id, settings.auto_approve_label):
|
||||
plane_sync.set_issue_approved(work_item_id) # индикация (AC-1), транзиентна*
|
||||
logger.info("Task …: label autoApprove → BRD auto-approved")
|
||||
plane_add_comment(work_item_id, "<auto-approve via label autoApprove>", author="analyst")
|
||||
send_telegram("✅ <ORC-NNN>: BRD авто-подтверждён (лейбл autoApprove)")
|
||||
auto = advance_stage(task_id, current_stage, repo, work_item_id, branch, finished_agent=None)
|
||||
result.advanced = auto.advanced; result.to_stage = auto.to_stage
|
||||
result.note = "auto-approved-via-label"
|
||||
return
|
||||
# (нет лейбла / fail-safe) → прежнее поведение: return, ждём человека.
|
||||
```
|
||||
|
||||
**Ключевое требование — НЕ дублировать переходную логику.** Авто-аппрув идёт через тот
|
||||
же `advance_stage(..., finished_agent=None)`, что и человеческий Approved-вебхук: ветка
|
||||
`check_analysis_approved` с `agent is None` → `qg_passed=True` (`approved-via-status`) →
|
||||
advance `analysis → architecture` → `mark_brd_review_ended` (клок) → штатные
|
||||
post-эффекты (карточка, plane-sync, enqueue architect). Единый источник истины перехода.
|
||||
|
||||
> *Транзиентность Approved-статуса:* сразу после advance `plane_notify_stage` выставит
|
||||
> статус `Architecture`, перекрыв `Approved`. Это ожидаемо — `set_issue_approved` даёт
|
||||
> мгновенную индикацию/симметрию, а **durable**-прозрачность несут лог + Telegram + Plane-коммент
|
||||
> (AC-7). Re-entrancy безопасна: вложенный `advance_stage` не возвращается в
|
||||
> `_handle_analysis_approved_flow` (та ветка требует `agent=='analyst'`; вложенный вызов
|
||||
> идёт с `finished_agent=None`) — рекурсии нет.
|
||||
|
||||
### D4. Врезка autoDeploy — `_handle_self_deploy_phase_a`, ранняя ветка
|
||||
|
||||
Сразу после `update_task_stage(task_id, "deploy")` + `notify_stage_change` +
|
||||
`self_deploy.clear_state(repo, work_item_id)` (всегда — wipe стейл-маркеров), ДО
|
||||
«ask-human» блока:
|
||||
|
||||
```
|
||||
if labels.auto_deploy_applies(repo) and labels.has_label(work_item_id, settings.auto_deploy_label):
|
||||
logger.info("Task …: label autoDeploy → prod deploy auto-confirmed")
|
||||
plane_add_comment(work_item_id, "<auto-confirm prod deploy via label autoDeploy>", author="deployer")
|
||||
send_telegram("🚀 <ORC-NNN>: прод-деплой авто-подтверждён (лейбл autoDeploy)")
|
||||
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result) # INITIATED + Deploying + finalizer
|
||||
return
|
||||
# (нет лейбла / fail-safe) → прежний Phase A: set_issue_awaiting_deploy + APPROVE_REQUESTED + «смените на Confirm Deploy».
|
||||
```
|
||||
|
||||
При autoDeploy пропускаются ТОЛЬКО индикативно-человеческие шаги (`set_issue_awaiting_deploy`
|
||||
+ `APPROVE_REQUESTED` + «ask-human» коммент/Telegram) — статус `Deploying` выставит сам
|
||||
Phase B. Идемпотентность прод-деплоя обеспечена существующим маркером `INITIATED` внутри
|
||||
`_handle_self_deploy_phase_b` (повторный заход — no-op). Phase B/C, merge-verify,
|
||||
regression-guard, post-deploy monitor — **неизменны**.
|
||||
|
||||
**Почему BR-5/AC-5 выполнены структурно:** Phase A достигается ТОЛЬКО после зелёных
|
||||
под-гейтов ребра `deploy-staging → deploy` (security → merge-gate → image-freshness →
|
||||
staging — они исполняются ВЫШЕ в `advance_stage` и при FAIL откатывают/возвращают БЕЗ
|
||||
выхода на Phase A). autoDeploy лишь заменяет ручной клик в точке, где все тех-проверки
|
||||
уже зелёные — он физически не может задеплоить сломанное.
|
||||
|
||||
### D5. Scope и kill-switch (флаги `src/config.py`)
|
||||
|
||||
| Флаг | Тип / дефолт | Назначение |
|
||||
|------|--------------|------------|
|
||||
| `auto_label_enabled` | `bool=True` (`ORCH_AUTO_LABEL_ENABLED`) | Глобальный kill-switch обоих авто-режимов. `False` → строго прежнее поведение, **ни одного нового сетевого вызова на гейтах** (AC-8). |
|
||||
| `auto_approve_label` | `str="autoApprove"` | Имя лейбла гейта BRD. |
|
||||
| `auto_deploy_label` | `str="autoDeploy"` | Имя лейбла гейта деплоя. |
|
||||
| `auto_label_repos` | `str=""` (CSV) | Scope. **Пусто → self-hosting only** (`orchestrator`). |
|
||||
| `auto_label_states_ttl_s` | `int=300` | TTL кэша карты лейблов проекта (образец `plane_states_ttl_s`). |
|
||||
|
||||
`auto_approve_applies`/`auto_deploy_applies` — калька `self_deploy_applies`:
|
||||
`auto_label_enabled=False` → всегда False; непустой `auto_label_repos` → только
|
||||
перечисленные репо; пустой → **self-hosting only** (`is_self_hosting_repo`). Решение
|
||||
по дефолту scope (BRD оставил выбор): **self-hosting only** — безопасный дефолт (BR-10),
|
||||
к тому же autoDeploy-врезка живёт в Phase A, которая существует только для self-hosting.
|
||||
Единый scope-флаг на оба лейбла (минимальная матрица); раздельные репо-скоупы — follow-up
|
||||
при необходимости.
|
||||
|
||||
**Порядок проверки на гейте (важно для AC-8):** `applies(repo)` проверяется ПЕРВЫМ
|
||||
(локальный, без сети). Только если `applies==True` вызывается `has_label` (сеть). При
|
||||
выключенном флаге `applies` сразу False → `has_label` не вызывается → нулевой сетевой
|
||||
оверхед, нулевая регрессия для enduro.
|
||||
|
||||
### D6. Идемпотентность и взаимодействие с reconciler/serial-gate
|
||||
|
||||
- **autoApprove vs реальный Approved-вебхук / reconciler F-2:** после авто-advance стадия
|
||||
= `architecture`. Поздний человеческий Approved или F-2 (plane-side) увидят уже
|
||||
`architecture` → не повторят analysis-advance (тот же эффект, что и человеческий
|
||||
double-click сегодня). Advance применяется один раз.
|
||||
- **autoDeploy:** идемпотентность — существующий маркер `INITIATED` (Phase B).
|
||||
- **serial-gate (ORCH-088):** сериализует claim analyst-job на уровне FIFO — авто-режим
|
||||
ортогонален (убирает паузы ВНУТРИ прохода одной задачи), не конфликтует.
|
||||
- **reconciler F-1** analysis не трогает (человеческий гейт) — авто-аппрув идёт через
|
||||
launcher-path, не через F-1.
|
||||
|
||||
### D7. Наблюдаемость (AC-7)
|
||||
|
||||
Каждый авто-проход → `logger.info` (label X → действие) + Telegram + Plane-коммент
|
||||
(автор `analyst` для BRD, `deployer` для деплоя — образец существующих гейт-комментов) +
|
||||
обновление live-карточки через штатный advance/notify. Аддитивный read-only блок
|
||||
`auto_labels` в `GET /queue` (`labels.snapshot()`: enabled, имена лейблов, scope) — образец
|
||||
блоков `reconcile`/`serial_gate`. Счётчики авто-проходов — best-effort/опционально (v1
|
||||
можно in-memory или опустить; БД не трогаем).
|
||||
|
||||
### D8. Схема БД — без изменений
|
||||
|
||||
Авто-режим stateless относительно БД: источник истины лейблов — Plane (читается на гейте);
|
||||
идемпотентность autoDeploy — существующие sentinel-маркеры (`APPROVE_REQUESTED`/`INITIATED`);
|
||||
клок `brd_review_*` уже существует (ORCH-087). Миграции нет (restart-safe через Plane/маркеры).
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- Минимальная, аддитивная поверхность изменения; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`
|
||||
неприкосновенны (AC-10). Единый источник истины перехода (переиспользование advance/Phase B).
|
||||
- Все тех-гейты на месте; autoDeploy структурно не может задеплоить сломанное (BR-5/AC-5).
|
||||
- Декларативно и обратимо (снял лейбл → ручной режим). Независимые лейблы (AC-9).
|
||||
- Fail-safe by default (never auto при любой неоднозначности, AC-6); kill-switch + scope
|
||||
→ нулевая регрессия для enduro (AC-8).
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- Approved-статус при autoApprove транзиентен (перекрывается `Architecture`) — durable-аудит
|
||||
несут лог/Telegram/коммент, не Plane-статус.
|
||||
- Чтение лейблов добавляет 1–2 GET к Plane на каждом из двух гейтов применимого репо (с TTL-кэшем
|
||||
карты лейблов; вызывается только когда `applies==True`). При недоступности Plane → fail-safe
|
||||
к ручному гейту (не блок конвейера).
|
||||
- Доверие выражается лейблом — оператор отвечает за то, что autoDeploy навешан осознанно
|
||||
(тех-гейты страхуют от поломки, но не от «не той фичи»).
|
||||
|
||||
**Инфра-предусловие:** лейблы `autoApprove`/`autoDeploy` должны существовать в Plane-проекте
|
||||
ORCH (создать однократно через labels API) — см. `07-infra-requirements.md`. Нет лейбла в
|
||||
проекте → `has_label` всегда False → ручной режим (fail-safe), без ошибок.
|
||||
|
||||
## Связанные
|
||||
- BRD/ТЗ/AC: `docs/work-items/ORCH-089/{01-brd,02-trz,03-acceptance-criteria}.md`
|
||||
- Образцы условной врезки: ADR-0003 (staging), 0006 (merge-gate), 0007 (self-deploy),
|
||||
0017 (serial-gate); ORCH-059 (Confirm Deploy status).
|
||||
- Глобальный ADR: `docs/architecture/adr/adr-0018-auto-label-gates.md`.
|
||||
63
docs/work-items/ORCH-089/07-infra-requirements.md
Normal file
63
docs/work-items/ORCH-089/07-infra-requirements.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 07 — Инфра-требования: ORCH-089 (авто-режим по лейблам)
|
||||
|
||||
## I-1. Создать лейблы в Plane-проекте ORCH (однократно, обязательно)
|
||||
|
||||
Авто-режим управляется лейблами на задаче. В Plane-проекте ORCH сейчас лейблов
|
||||
`autoApprove`/`autoDeploy` **нет** — их нужно создать один раз через labels API:
|
||||
|
||||
```
|
||||
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/labels/
|
||||
Headers: PLANE_HEADERS
|
||||
Body: {"name": "autoApprove"}
|
||||
|
||||
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/labels/
|
||||
Body: {"name": "autoDeploy"}
|
||||
```
|
||||
|
||||
Имена должны **точно** соответствовать `auto_approve_label` / `auto_deploy_label`
|
||||
(дефолты `autoApprove` / `autoDeploy`). Сопоставление в коде — по нормализованному имени
|
||||
(`strip().casefold()`), т.е. регистр/пробелы не критичны, но рекомендуется создать ровно
|
||||
как в дефолте.
|
||||
|
||||
**Fail-safe при отсутствии:** если лейбл в проекте не создан, `labels.has_label` всегда
|
||||
вернёт `False` → задача идёт ручным путём (нулевой риск, без ошибок). То есть создание
|
||||
лейблов — предусловие активации фичи, а не условие стабильности конвейера.
|
||||
|
||||
## I-2. Сброс кэша состояний/лейблов после создания (рекомендуется)
|
||||
|
||||
`get_project_labels` кэширует карту лейблов проекта с TTL `auto_label_states_ttl_s`
|
||||
(дефолт 300с). После создания новых лейблов карта подтянется автоматически в течение TTL;
|
||||
для немедленного эффекта — рестарт не требуется, достаточно дождаться TTL или (если будет
|
||||
добавлен) вызвать reload-хелпер кэша лейблов по образцу `reload_project_states`.
|
||||
|
||||
## I-3. Конфигурация (env, хост mva154)
|
||||
|
||||
По умолчанию фича включена (`auto_label_enabled=True`) и применима только к self-hosting
|
||||
репо (`auto_label_repos=""` → `orchestrator`). Управляющие env (опционально, в `.env`):
|
||||
|
||||
| Env | Дефолт | Эффект |
|
||||
|-----|--------|--------|
|
||||
| `ORCH_AUTO_LABEL_ENABLED` | `true` | Глобальный kill-switch. `false` → оба гейта ручные, нулевой сетевой оверхед. |
|
||||
| `ORCH_AUTO_APPROVE_LABEL` | `autoApprove` | Имя лейбла гейта BRD. |
|
||||
| `ORCH_AUTO_DEPLOY_LABEL` | `autoDeploy` | Имя лейбла гейта деплоя. |
|
||||
| `ORCH_AUTO_LABEL_REPOS` | `` (пусто) | CSV scope. Пусто → self-hosting only. |
|
||||
| `ORCH_AUTO_LABEL_STATES_TTL_S` | `300` | TTL кэша карты лейблов проекта. |
|
||||
|
||||
## I-4. Сетевые/доступ
|
||||
|
||||
Новых endpoint оркестратора нет. Дополнительные **исходящие** вызовы к Plane API v1
|
||||
(те же креды `PLANE_HEADERS`, таймаут 10с):
|
||||
- `GET …/issues/{issue_id}/` (поле `labels`) — чтение лейблов задачи на гейте;
|
||||
- `GET …/projects/{pid}/labels/` — карта лейблов проекта (кэш с TTL);
|
||||
- `PATCH …/issues/{issue_id}/` `{"state": <approved_uuid>}` — индикация авто-аппрува.
|
||||
|
||||
Вызовы — только когда `applies(repo)==True` (kill-switch off / репо вне scope → нет
|
||||
вызовов). Недоступность Plane → fail-safe к ручному гейту (конвейер не блокируется).
|
||||
|
||||
## I-5. Топология / прод-риск
|
||||
|
||||
Self-hosting не меняется: autoDeploy лишь авто-инициирует **существующий** Phase B
|
||||
(detached host-деплой через `scripts/orchestrator-deploy-hook.sh`). Никакого нового пути
|
||||
рестарта прод-контейнера не вводится. Phase C / merge-verify / regression-guard /
|
||||
post-deploy monitor продолжают верифицировать результат. Раскат — под kill-switch
|
||||
(`ORCH_AUTO_LABEL_ENABLED`), деплой self — через обязательный staging-гейт (8501), как всегда.
|
||||
20
docs/work-items/ORCH-089/10-tech-risks.md
Normal file
20
docs/work-items/ORCH-089/10-tech-risks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 10 — Технические риски: ORCH-089 (авто-режим по лейблам)
|
||||
|
||||
| # | Риск | Вероятность / Impact | Митигация |
|
||||
|---|------|----------------------|-----------|
|
||||
| R-1 | **Ложный авто-проход гейта** при ошибке чтения лейблов (Plane вернул мусор/частичный ответ) → задача авто-проходит, хотя лейбла нет. | Низк. / **Критич.** (групповой self-hosting риск). | `has_label` обёрнут в единый `try/except → False`; `fetch_issue_labels` различает `None` (ошибка) и `[]` (нет лейблов); неоднозначность имени → False. Любая неопределённость → ручной гейт (BR-6/AC-6). Дополнительно: тех-гейты страхуют от деплоя сломанного даже при ложном autoDeploy. |
|
||||
| R-2 | **Двойной advance / гонка** автоApprove с реальным Approved-вебхуком или reconciler F-2. | Сред. / Низк. | Advance применяется один раз: после авто-advance стадия = `architecture`; поздний Approved/F-2 видят `architecture` и не повторяют analysis-переход (как человеческий double-click сегодня). |
|
||||
| R-3 | **Двойной прод-деплой** при autoDeploy (повторный заход Phase A / дубль staging-deployer-finished). | Низк. / Высок. | Идемпотентность Phase B по маркеру `INITIATED`. Phase A после первого прохода advance'ит стадию на `deploy` → guard `current_stage=="deploy-staging"` больше не матчится, повторный Phase A не запускается. `clear_state` в Phase A wipe'ит маркеры только при входе в свежий проход. |
|
||||
| R-4 | **Re-entrancy** вложенного `advance_stage` из `_handle_analysis_approved_flow` → рекурсия. | Низк. / Сред. | Вложенный вызов идёт с `finished_agent=None` и попадает в ветку `approved-via-status`, НЕ в `_handle_analysis_approved_flow` (та требует `agent=='analyst'`). Рекурсии нет. |
|
||||
| R-5 | **Регрессия для enduro / при выключенном флаге** (лишние сетевые вызовы, изменение поведения). | Низк. / Высок. | `applies(repo)` (локальный, без сети) проверяется ПЕРВЫМ; `has_label` (сеть) — только при `applies==True`. `auto_label_enabled=False` или репо вне scope → `applies==False` → нулевой сетевой оверхед, поведение 1:1 (AC-8). |
|
||||
| R-6 | **Лейбл не создан в Plane-проекте** → фича «молча не работает». | Сред. / Низк. | `has_label==False` → ручной гейт (fail-safe, не ошибка). Инфра-предусловие задокументировано (`07-infra-requirements.md` I-1). Прозрачность: отсутствие авто-прохода видно по тому, что задача встала на ручном гейте. |
|
||||
| R-7 | **Транзиентность Approved-статуса** (перекрывается `Architecture` сразу после advance) → оператор не увидит, что прошёл именно авто-аппрув. | Сред. / Низк. | Durable-прозрачность — лог + Telegram + Plane-коммент («auto-approved via label autoApprove») + live-карточка (AC-7). Plane-статус Approved — лишь мгновенная индикация. |
|
||||
| R-8 | **Stale-кэш карты лейблов** (`get_project_labels`) → недавно созданный/снятый лейбл не виден. | Низк. / Низк. | TTL `auto_label_states_ttl_s` (300с) — самозалечивание без рестарта (образец `plane_states_ttl_s`/ORCH-068). Окно ≤ TTL. |
|
||||
| R-9 | **Plane API недоступен на гейте** → задержка/блок конвейера. | Низк. / Сред. | Таймаут 10с (как соседи), never-raise → «нет авто» → ручной гейт. Конвейер не блокируется; задача просто ждёт человека (прежнее поведение). |
|
||||
| R-10 | **Доверие выражено лейблом ошибочно** (autoDeploy навешан не на ту задачу). | Сред. / Сред. | Тех-гейты блокируют поломку (не «не ту фичу»). Лейбл обратим (снять → ручной режим). Зона ответственности оператора; прозрачность авто-прохода (AC-7) даёт раннее обнаружение. |
|
||||
|
||||
## Вывод
|
||||
Доминирующий риск — **R-1 (ложный авто-проход)**; закрывается строгим never-raise / fail-safe
|
||||
контрактом leaf-модуля и тем, что тех-гейты остаются последней линией защиты. Все риски
|
||||
укладываются в установленные проектом паттерны (условный под-гейт + kill-switch + scope +
|
||||
fail-safe), новых классов риска фича не вводит.
|
||||
91
docs/work-items/ORCH-089/12-review.md
Normal file
91
docs/work-items/ORCH-089/12-review.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-089
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-089
|
||||
|
||||
## Summary
|
||||
|
||||
Авто-режим по лейблам Plane (`autoApprove` + `autoDeploy`) реализован строго по ТЗ
|
||||
и ADR: аддитивно, по образцу условных под-гейтов (ORCH-035/043/058/088). Снимаются
|
||||
**только два человеческих решения** (гейт BRD `Approved`, гейт прод-деплоя
|
||||
`Confirm Deploy`); ни одна техническая проверка не тронута. Соответствие всем осям
|
||||
(ТЗ, ADR, качество кода, тесты) — полное; документация-golden-source обновлена в том
|
||||
же PR. Блокирующих findings нет. **Вердикт: APPROVED.**
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
### 1. Соответствие ТЗ (`02-trz.md`)
|
||||
- ✅ Leaf `src/labels.py` (never-raise): `auto_approve_applies`/`auto_deploy_applies`
|
||||
(локальный scope-чек ПЕРВЫМ), `has_label` (единственный сетевой вызов, только при
|
||||
`applies==True` → нулевой оверхед при выключенном флаге, §7/AC-8), `snapshot`.
|
||||
- ✅ `src/plane_sync.py`: `fetch_issue_labels` (`None` при ошибке ≠ `[]`),
|
||||
`get_project_labels` (`{normalized→uuid}`, TTL-кэш `auto_label_states_ttl_s` по
|
||||
образцу `get_project_states`, сентинел `__AMBIGUOUS__` при коллизии имён),
|
||||
`set_issue_approved` (1:1 зеркало `set_issue_in_review`).
|
||||
- ✅ Врезка autoApprove — `_handle_analysis_approved_flow` (ветка `files_ok`) ПОСЛЕ
|
||||
`In Review`+коммента; advance идёт через тот же `advance_stage(..., finished_agent=None)`,
|
||||
что человеческий Approved (без дублирования переходной логики, §3.1).
|
||||
- ✅ Врезка autoDeploy — `_handle_self_deploy_phase_a` после advance на `deploy`+
|
||||
`clear_state`, ДО «ask-human»; Phase B запускается тем же `_handle_self_deploy_phase_b`
|
||||
(§3.2).
|
||||
- ✅ Флаги (`config.py`): `auto_label_enabled`, `auto_approve_label`, `auto_deploy_label`,
|
||||
`auto_label_repos` (пусто → self-hosting only), `auto_label_states_ttl_s` (§7).
|
||||
- ✅ Блок наблюдаемости `auto_labels` в `GET /queue` (§8).
|
||||
- ✅ БД-схема и QG-реестр не трогаются (§5/§6) — подтверждено: `stages.py`,
|
||||
`qg/checks.py`, `db.py` отсутствуют в diff feat-коммита.
|
||||
|
||||
### 2. Соответствие ADR (`06-adr/ADR-001`, global `adr-0018`)
|
||||
- ✅ Реализация 1:1 с решениями ADR: D1 (поверхность leaf + порядок резолва `has_label`),
|
||||
D5 (scope: пусто → self-hosting), fail-safe «never auto on doubt», ambiguity-сентинел.
|
||||
- ✅ Глобальный сквозной ADR `adr-0018-auto-label-gates.md` заведён.
|
||||
- ✅ Подтверждена корректность пути advance: `advance_stage` с `agent=None` идёт в
|
||||
ветку `approved-via-status` (qg_passed, без повторного `check_analysis_approved`) →
|
||||
`analysis → architecture` + `mark_brd_review_ended`. Re-entrancy безопасна
|
||||
(вложенный вызов с `finished_agent=None` не входит в analyst-ветку).
|
||||
|
||||
### 3. Качество кода
|
||||
- ✅ never-raise соблюдён во всех публичных функциях (`labels.py`, новые `plane_sync`-хелперы).
|
||||
- ✅ Нет дублирования переходной логики — переиспользованы `advance_stage` и
|
||||
`_handle_self_deploy_phase_b` (включая существующую идемпотентность `INITIATED`).
|
||||
- ✅ Прозрачность (AC-7) во всех трёх каналах: лог + Telegram (`send_telegram`) +
|
||||
Plane-коммент (`plane_add_comment`), плюс live-карточка через штатный advance.
|
||||
- ✅ Docstrings содержательные; кликабельный номер задачи (`link_for`) в уведомлениях.
|
||||
|
||||
### 4. Тесты
|
||||
- ✅ 43 целевых теста (TC-01…TC-26, 7 модулей) — все зелёные.
|
||||
- ✅ Регрессия: 377 релевантных тестов (stage/plane/analysis/deploy/self_deploy/webhook)
|
||||
— все зелёные. AC-10 (инварианты) подтверждён.
|
||||
|
||||
## Документация
|
||||
Обновлена полностью в том же PR (AC-11):
|
||||
- `CLAUDE.md` — раздел «Авто-режим по лейблам» (флаги, инвариант «снимает только
|
||||
человеческое решение»);
|
||||
- `docs/architecture/README.md` — описание врезок autoApprove/autoDeploy + флаги;
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`;
|
||||
- `06-adr/ADR-001-auto-label-gates.md` + global `docs/architecture/adr/adr-0018-auto-label-gates.md`;
|
||||
- `07-infra-requirements.md` — предусловие создания лейблов `autoApprove`/`autoDeploy`
|
||||
в Plane-проекте ORCH.
|
||||
|
||||
Статус: документация синхронна с кодом. Требование CLAUDE.md §2/§6 выполнено.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- Нет.
|
||||
|
||||
### P1 — Must fix
|
||||
- Нет.
|
||||
|
||||
### P2 — Should fix
|
||||
- Нет.
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- `set_issue_approved` обращается к `get_project_states(pid)["approved"]` прямым
|
||||
индексом (потенциальный `KeyError`, если ключ отсутствует). На практике защищено:
|
||||
ключ `approved` гарантирован в `_DEFAULT_STATES`, паттерн 1:1 повторяет
|
||||
существующий `set_issue_in_review`, а вызов обёрнут внешним `try/except` в
|
||||
`advance_stage` (деградирует к ручному гейту). Косметика, не блокер.
|
||||
88
docs/work-items/ORCH-089/13-test-report.md
Normal file
88
docs/work-items/ORCH-089/13-test-report.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-089
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-089
|
||||
|
||||
Авто-режим по лейблам: autoApprove (авто-BRD) + autoDeploy (авто-деплой).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Branch: feature/ORCH-089-autoapprove-brd-autodeploy
|
||||
- Дата: 2026-06-09
|
||||
|
||||
## Предусловия
|
||||
- Review verdict: **APPROVED** (`12-review.md`, P0/P1 — нет).
|
||||
- Prod health (8500): `{"status":"ok"}` — конвейер всех проектов жив, деструктивные операции не выполнялись.
|
||||
|
||||
## Результаты (test-plan `04-test-plan.yaml`)
|
||||
|
||||
Все 26 TC из плана покрыты 43 целевыми тестами (7 модулей). Сопоставление с критериями приёмки (`03-acceptance-criteria.md`):
|
||||
|
||||
| TC ID | Описание | AC | Результат |
|
||||
|-------|----------|-----|-----------|
|
||||
| TC-01 | has_label=True когда лейбл присутствует | AC-1 | PASS |
|
||||
| TC-02 | has_label=False когда лейбла нет | AC-4 | PASS |
|
||||
| TC-03 | has_label при ошибке Plane/таймауте → fail-safe, never-raise | AC-6 | PASS |
|
||||
| TC-04 | Нормализация имени лейбла; неоднозначность → нет авто | AC-6 | PASS |
|
||||
| TC-05 | applies: scope CSV + self-hosting; пустой scope по дефолту | AC-8 | PASS |
|
||||
| TC-06 | auto_label_enabled=False → нет авто без сетевых вызовов | AC-8 | PASS |
|
||||
| TC-07 | fetch_issue_labels парсит labels + резолв uuid→имя | AC-1 | PASS |
|
||||
| TC-08 | Карта лейблов проекта кэшируется с TTL | AC-8 | PASS |
|
||||
| TC-09 | set_issue_approved PATCH в Approved-UUID; never-raise | AC-1 | PASS |
|
||||
| TC-10 | autoApprove → авто-advance analysis→architecture, Approved, клок закрыт | AC-1 | PASS |
|
||||
| TC-11 | Без лейбла autoApprove → In Review, return без advance | AC-4 | PASS |
|
||||
| TC-12 | autoApprove без артефактов → НЕ advance | AC-5 | PASS |
|
||||
| TC-13 | autoApprove через тот же advance-путь; идемпотентно | AC-1 | PASS |
|
||||
| TC-14 | autoApprove: лог + Telegram/карточка + Plane-коммент | AC-7 | PASS |
|
||||
| TC-15 | autoDeploy + Phase A → авто _handle_self_deploy_phase_b | AC-2 | PASS |
|
||||
| TC-16 | Без лейбла autoDeploy → Awaiting Deploy, ждёт человека | AC-4 | PASS |
|
||||
| TC-17 | autoDeploy идемпотентен: маркер INITIATED → no-op | AC-2 | PASS |
|
||||
| TC-18 | autoDeploy не-self/вне scope → no-op | AC-8 | PASS |
|
||||
| TC-19 | autoDeploy: лог + Telegram + Plane-коммент | AC-7 | PASS |
|
||||
| TC-20 | Только autoApprove: BRD авто, деплой ждёт человека | AC-9 | PASS |
|
||||
| TC-21 | Только autoDeploy: BRD ждёт человека, деплой авто | AC-9 | PASS |
|
||||
| TC-22 | auto_label_enabled=False → оба гейта ручные | AC-8 | PASS |
|
||||
| TC-23 | Оба лейбла + зелёные тех-гейты → analysis→deploy автономно | AC-3 | PASS |
|
||||
| TC-24 | autoDeploy + красный staging/merge-gate → Phase B НЕ инициирован | AC-5 | PASS |
|
||||
| TC-25 | Регресс: без лейблов оба гейта как до ORCH-089 | AC-4 | PASS |
|
||||
| TC-26 | Инвариант: STAGE_TRANSITIONS и QG_CHECKS не изменены | AC-10 | PASS |
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → 200, активные задачи перечислены (ORCH-089 = `testing`) — OK
|
||||
- `GET /queue` → 200, блоки наблюдаемости присутствуют — OK
|
||||
|
||||
> Примечание: блок `auto_labels` в `GET /queue` на 8500 пока отсутствует — это ожидаемо:
|
||||
> прод-контейнер исполняет код до ORCH-089 (задача ещё в `testing`, не задеплоена).
|
||||
> Блок добавляется кодом ветки и покрыт юнит-тестами (snapshot/observability) выше.
|
||||
|
||||
## Вывод pytest (полный регресс)
|
||||
|
||||
```
|
||||
======================= 1157 passed, 1 warning in 37.99s =======================
|
||||
```
|
||||
|
||||
Целевой набор ORCH-089 (7 модулей):
|
||||
|
||||
```
|
||||
tests/test_labels.py ................ (14)
|
||||
tests/test_plane_sync_labels.py ..... (11)
|
||||
tests/test_auto_approve_brd.py ...... (5)
|
||||
tests/test_auto_deploy.py ........... (5)
|
||||
tests/test_auto_label_combinations.py (3)
|
||||
tests/test_auto_labels_integration.py (3)
|
||||
tests/test_auto_labels_invariants.py . (2)
|
||||
======================== 43 passed, 1 warning in 1.09s =========================
|
||||
```
|
||||
|
||||
Единственный warning — `PydanticDeprecatedSince20` (class-based config в `src/config.py`),
|
||||
не связан с ORCH-089, присутствует в baseline.
|
||||
|
||||
## Итог
|
||||
|
||||
**PASS** — все 26 TC плана зелёные, полный регресс 1157/1157 пройден, smoke-тесты OK,
|
||||
инварианты (STAGE_TRANSITIONS/QG_CHECKS, AC-10) подтверждены. Задача готова к `deploy-staging`.
|
||||
12
docs/work-items/ORCH-089/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-089/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-089
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
39
docs/work-items/ORCH-089/15-staging-log.md
Normal file
39
docs/work-items/ORCH-089/15-staging-log.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-09T09:29:58Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live `orchestrator-staging` stand (8501),
|
||||
run canonically inside the container (`docker exec orchestrator-staging python3
|
||||
/repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`).
|
||||
|
||||
**Result: 8/10 checks PASS — exit code 0 → SUCCESS.**
|
||||
|
||||
All REAL (pipeline) checks are green. The two failures are the known sandbox-infra
|
||||
checks C9a/C9b (branch in `orchestrator-sandbox` / analyst job enqueued), which depend
|
||||
on SANDBOX bot accounts being members of the SANDBOX project — not on the pipeline.
|
||||
They are waived per ORCH-061 (`staging_infra_tolerance_enabled=True`), so the script
|
||||
still exits 0 (fail-closed for any REAL check).
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
## Check breakdown
|
||||
|
||||
- ✓ A1 GET /health → 200 status=ok
|
||||
- ✓ A2 GET /queue → 200 with counts/max_concurrency/resilience
|
||||
- ✓ A3 ORCH_STAGING=true (not prod)
|
||||
- ✓ B4 Plane: sandbox project accessible
|
||||
- ✓ B5 Gitea: orchestrator-sandbox accessible, push=true
|
||||
- ✓ B6 Registry: sandbox present, prod ET/ORCH absent
|
||||
- ✓ C7 Create issue in Plane SANDBOX
|
||||
- ✓ C8 Trigger pipeline via /webhook/plane
|
||||
- ✗ C9a Branch appears in orchestrator-sandbox (SANDBOX_INFRA — waived)
|
||||
- ✗ C9b Analyst job enqueued in staging queue (SANDBOX_INFRA — waived)
|
||||
|
||||
CLEANUP: test Plane issue deleted (HTTP 204); no branch to delete.
|
||||
14
docs/work-items/ORCH-089/16-post-deploy-log.md
Normal file
14
docs/work-items/ORCH-089/16-post-deploy-log.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
post_deploy_status: HEALTHY
|
||||
action_taken: NONE
|
||||
work_item: ORCH-089
|
||||
window_s: 900
|
||||
checks_total: 30
|
||||
checks_failed: 0
|
||||
---
|
||||
|
||||
# Post-deploy log — ORCH-021 post-deploy monitor
|
||||
|
||||
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
|
||||
|
||||
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.
|
||||
@@ -418,6 +418,32 @@ class AgentLauncher:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _materialize_deferred_branch(
|
||||
self, repo: str, branch: str, work_item_id: str | None, title: str | None
|
||||
) -> None:
|
||||
"""ORCH-088 (ADR-001 D1): create the deferred Gitea branch + initial docs.
|
||||
|
||||
Relocated from ``webhooks.plane.start_pipeline``: the two coroutines are run
|
||||
SYNCHRONOUSLY here (this method executes in the worker THREAD — no running
|
||||
event loop — so ``asyncio.run`` is safe, R-4). Sequence mirrors the original
|
||||
start_pipeline order so the downstream worktree/PR flow is identical:
|
||||
``_create_gitea_branch`` (from a fresh ``main``) -> ``_create_initial_docs``.
|
||||
Both are idempotent (409/422 -> no-op) so a re-claim after a restart is safe.
|
||||
A transient Gitea error PROPAGATES so the caller (_spawn) fails the launch and
|
||||
the queue worker requeues the job for a later tick (never a half-cut state).
|
||||
"""
|
||||
import asyncio
|
||||
from ..webhooks.plane import _create_gitea_branch, _create_initial_docs
|
||||
|
||||
name = title or work_item_id or branch
|
||||
logger.info(
|
||||
f"ORCH-088: materialising deferred branch '{branch}' for {repo} "
|
||||
f"({work_item_id}) at analyst-job claim"
|
||||
)
|
||||
asyncio.run(_create_gitea_branch(repo, branch))
|
||||
if work_item_id:
|
||||
asyncio.run(_create_initial_docs(repo, branch, work_item_id, name))
|
||||
|
||||
def _spawn(self, agent: str, repo: str, task_content: str = None,
|
||||
task_id: int = None, job_id: int = None) -> int:
|
||||
"""Shared spawn implementation for launch() and launch_job().
|
||||
@@ -437,9 +463,33 @@ class AgentLauncher:
|
||||
raise FileNotFoundError(f"Repo not found: {local_repo_path}")
|
||||
|
||||
# Determine branch (needed before we touch the worktree / task file).
|
||||
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
|
||||
_br_row = (
|
||||
get_db().execute(
|
||||
"SELECT branch, work_item_id, title FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
if task_id else None
|
||||
)
|
||||
agent_branch = _br_row[0] if _br_row else "main"
|
||||
|
||||
# ORCH-088 (FR-1/AC-6, ADR-001 D1): materialise a DEFERRED branch cut. When
|
||||
# the serial gate applies, start_pipeline did NOT create the Gitea branch /
|
||||
# initial docs — they were deferred to this claim so the cut happens from a
|
||||
# fresh origin/main that already contains the predecessor. We only reach this
|
||||
# claim because the gate is OPEN (predecessor done), so it is now safe. This
|
||||
# runs ONLY for the analyst-job (pipeline entry); every later stage reuses the
|
||||
# existing branch. Idempotent (409/422 -> no-op) so a re-claim is safe. On a
|
||||
# transient Gitea error this raises -> _drain_once requeues the job (R-4).
|
||||
if agent == "analyst" and _br_row is not None:
|
||||
try:
|
||||
from .. import serial_gate
|
||||
_applies = serial_gate.serial_gate_applies(repo)
|
||||
except Exception: # noqa: BLE001 - never let the gate check block a launch
|
||||
_applies = False
|
||||
if _applies:
|
||||
self._materialize_deferred_branch(
|
||||
repo, agent_branch, _br_row[1], _br_row[2]
|
||||
)
|
||||
|
||||
# ORCH-41: resolve the Plane project uuid for this repo so per-project
|
||||
# model/effort overrides apply. Unknown repo -> None (env/default only).
|
||||
from ..projects import get_project_by_repo
|
||||
|
||||
@@ -433,6 +433,31 @@ class Settings(BaseSettings):
|
||||
task_deps_enabled: bool = True
|
||||
task_deps_source: str = "db"
|
||||
|
||||
# ORCH-088 (Этап 1, serial e2e): per-repo serial gate. A new task's analyst-job
|
||||
# does NOT enter analysis (no branch cut, no analyst agent) while the same repo
|
||||
# has another unfinished task (tasks.stage != 'done') OR the repo is frozen
|
||||
# (repo_freeze). The gate lives in claim_next_job (offline-safe hot path, like
|
||||
# the ORCH-026 dep-gate) + the branch cut is deferred from start_pipeline to the
|
||||
# analyst-job claim (launcher) so the branch base is always a fresh origin/main
|
||||
# that already contains the predecessor (anti-stale-base, AC-6). All additive,
|
||||
# never-raise, restart-safe; STAGE_TRANSITIONS / QG_CHECKS unchanged. See
|
||||
# docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md.
|
||||
# serial_gate_enabled -> kill-switch (env ORCH_SERIAL_GATE_ENABLED).
|
||||
# False -> claim_next_job AND start_pipeline are 1:1
|
||||
# as before ORCH-088 (clause omitted, branch cut in
|
||||
# start_pipeline) — zero regression (AC-7).
|
||||
# serial_gate_repos -> CSV scope (env ORCH_SERIAL_GATE_REPOS). Empty ->
|
||||
# applies to ALL registered repos (D5); non-empty ->
|
||||
# only the listed repos. Repo tokens are sanitised
|
||||
# (^[A-Za-z0-9._-]+$) before being embedded in SQL.
|
||||
# serial_gate_freeze_enabled-> independent tumbler for the FR-5 rollback-freeze
|
||||
# layer (env ORCH_SERIAL_GATE_FREEZE_ENABLED). False
|
||||
# -> freeze is neither set (post-deploy DEGRADED) nor
|
||||
# consulted in the claim gate.
|
||||
serial_gate_enabled: bool = True
|
||||
serial_gate_repos: str = ""
|
||||
serial_gate_freeze_enabled: bool = True
|
||||
|
||||
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify
|
||||
# under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a
|
||||
# secondary deterministic (no-LLM) guard checks that a declarative set of markers
|
||||
@@ -462,6 +487,37 @@ class Settings(BaseSettings):
|
||||
# *_repos, since auto-create is semantically inseparable from merge-verify.
|
||||
merge_verify_autocreate_pr_enabled: bool = True
|
||||
|
||||
# ORCH-089: auto-mode by Plane labels — autoApprove (BRD gate) + autoDeploy
|
||||
# (prod-deploy gate). Two HUMAN gates of the pipeline (analysis: wait for a
|
||||
# manual Approved; deploy Phase A: wait for a manual Confirm Deploy) are the
|
||||
# only blockers of an autonomous batch run (epic ORCH-088). ORCH-089 lifts ONLY
|
||||
# those two human decisions — selectively (a Plane label on the issue),
|
||||
# declaratively, reversibly, WITHOUT touching a single technical check. Additive
|
||||
# leaf (src/labels.py, never-raise) + two point insertions + flags;
|
||||
# STAGE_TRANSITIONS / QG_CHECKS / check_* / DB schema are NOT touched. See
|
||||
# docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md.
|
||||
# auto_label_enabled -> global kill-switch for BOTH auto-modes (env
|
||||
# ORCH_AUTO_LABEL_ENABLED). False -> strictly the prior
|
||||
# behaviour (both gates manual), AND no new network call
|
||||
# on the gates (applies() returns False first, before
|
||||
# has_label is consulted) — zero regression (AC-8).
|
||||
# auto_approve_label -> Plane label name for the BRD gate (env
|
||||
# ORCH_AUTO_APPROVE_LABEL).
|
||||
# auto_deploy_label -> Plane label name for the deploy gate (env
|
||||
# ORCH_AUTO_DEPLOY_LABEL).
|
||||
# auto_label_repos -> CSV scope (env ORCH_AUTO_LABEL_REPOS). Empty ->
|
||||
# self-hosting only (orchestrator), the safe default
|
||||
# (the autoDeploy insertion lives in Phase A, which only
|
||||
# exists for the self-hosting repo). Non-empty -> only
|
||||
# the listed repos.
|
||||
# auto_label_states_ttl_s -> TTL (seconds) of the per-project label-map cache
|
||||
# (mirrors plane_states_ttl_s); 0 -> lifetime cache.
|
||||
auto_label_enabled: bool = True
|
||||
auto_approve_label: str = "autoApprove"
|
||||
auto_deploy_label: str = "autoDeploy"
|
||||
auto_label_repos: str = ""
|
||||
auto_label_states_ttl_s: int = 300
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
34
src/db.py
34
src/db.py
@@ -168,6 +168,26 @@ def init_db():
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_messages_open
|
||||
ON tracker_messages(task_id) WHERE deleted_at IS NULL;
|
||||
""")
|
||||
# ORCH-088 (FR-5, ADR-001 D2): durable per-repo rollback-freeze. After a
|
||||
# post-deploy DEGRADED verdict the repo is frozen so the serial gate stays
|
||||
# CLOSED unconditionally (the degraded task is already stage='done' — BR-7 — so
|
||||
# the ordinary active-task gate would not hold it) until an operator clears it
|
||||
# via POST /serial-gate/unfreeze. Append-only journal: an ACTIVE freeze for repo
|
||||
# R ⇔ a row with repo=R AND cleared_at IS NULL. Purely ADDITIVE (CREATE
|
||||
# TABLE/INDEX IF NOT EXISTS) -> idempotent, restart-safe on the live shared prod
|
||||
# DB (enduro-trails data untouched). See 08-data-requirements.md.
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS repo_freeze (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo TEXT NOT NULL,
|
||||
frozen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reason TEXT,
|
||||
work_item_id TEXT,
|
||||
cleared_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_freeze_active
|
||||
ON repo_freeze (repo, cleared_at);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -588,6 +608,19 @@ def claim_next_job() -> dict | None:
|
||||
" WHERE d.task_id = jobs.task_id AND t.stage != 'done'"
|
||||
") "
|
||||
)
|
||||
# ORCH-088 (FR-1, ADR-001 D1): per-repo serial gate. An analyst-job of a NEW
|
||||
# task is NOT claimable while the same repo has another unfinished task OR is
|
||||
# frozen. The fragment is built in the serial_gate leaf (sanitised repo scope,
|
||||
# fail-OPEN on any build error so a transient fault never wedges the queue of
|
||||
# ALL projects — AC-8). Jobs of an already-active task (architect/.../deployer)
|
||||
# are unaffected — the gate keys on jobs.agent='analyst' only. Reads only the
|
||||
# local DB (offline-safe hot path, NFR-2).
|
||||
serial_gate = ""
|
||||
try:
|
||||
from . import serial_gate as _serial_gate
|
||||
serial_gate = _serial_gate.build_claim_clause()
|
||||
except Exception: # noqa: BLE001 - fail-OPEN: never wedge the claim
|
||||
serial_gate = ""
|
||||
conn = get_db()
|
||||
try:
|
||||
while True:
|
||||
@@ -595,6 +628,7 @@ def claim_next_job() -> dict | None:
|
||||
"SELECT id FROM jobs WHERE status='queued' "
|
||||
"AND (available_at IS NULL OR available_at <= datetime('now')) "
|
||||
f"{dep_gate}"
|
||||
f"{serial_gate}"
|
||||
"ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
|
||||
133
src/labels.py
Normal file
133
src/labels.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""ORCH-089: auto-mode by Plane labels — autoApprove + autoDeploy (pure logic).
|
||||
|
||||
Leaf module — pure, unit-testable logic over the config flags + the Plane label
|
||||
helpers in ``plane_sync``. Mirrors the leaf pattern of ``src/serial_gate.py`` /
|
||||
``src/self_deploy.py``: imports only ``config`` (and lazily ``plane_sync`` /
|
||||
``qg.checks`` / ``projects``), never ``stage_engine`` / ``launcher``.
|
||||
|
||||
What it decides (ADR-001 D1):
|
||||
* Whether the auto-mode is in scope for a repo (``auto_approve_applies`` /
|
||||
``auto_deploy_applies``) — a LOCAL, network-free check evaluated FIRST.
|
||||
* Whether a given Plane label is present on an issue (``has_label``) — the only
|
||||
network call, made ONLY after ``applies()`` is True, so a disabled kill-switch
|
||||
costs zero network and yields zero regression (AC-8).
|
||||
|
||||
never-raise contract (BR-6/AC-6, fail-safe to the MANUAL gate): every public
|
||||
function degrades to "no auto" on ANY error / ambiguity / Plane unavailability.
|
||||
There is NO fail-open here — the conservative default is always "no auto"
|
||||
(human gate stays), so an error can never auto-pass a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.labels")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope / kill-switch (mirrors self_deploy_applies / serial_gate_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _auto_label_applies(repo: str) -> bool:
|
||||
"""Shared scope check for both auto-modes (ADR-001 D5).
|
||||
|
||||
* ``auto_label_enabled=False`` -> always False (kill-switch; both gates 1:1
|
||||
as before ORCH-089, and — crucially — ``has_label`` is never consulted, so
|
||||
no new network call on the gate, AC-8).
|
||||
* ``auto_label_repos`` (CSV) non-empty -> real only for the listed repos.
|
||||
* empty CSV -> self-hosting only (``orchestrator``) — the safe default
|
||||
(the autoDeploy insertion lives in Phase A, which only exists for the
|
||||
self-hosting repo).
|
||||
Never raises -> False on error (degrade to "no auto" = manual gate).
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "auto_label_enabled", False):
|
||||
return False
|
||||
raw = (getattr(settings, "auto_label_repos", "") or "").strip()
|
||||
if raw:
|
||||
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
|
||||
return (repo or "").strip().lower() in allowed
|
||||
# Lazy import keeps this module a leaf (avoids importing qg at load).
|
||||
from .qg.checks import is_self_hosting_repo
|
||||
return is_self_hosting_repo(repo)
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("_auto_label_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def auto_approve_applies(repo: str) -> bool:
|
||||
"""Whether the autoApprove (BRD gate) auto-mode is in scope for ``repo``."""
|
||||
return _auto_label_applies(repo)
|
||||
|
||||
|
||||
def auto_deploy_applies(repo: str) -> bool:
|
||||
"""Whether the autoDeploy (prod-deploy gate) auto-mode is in scope for ``repo``."""
|
||||
return _auto_label_applies(repo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label presence (the ONLY network call; ADR-001 D1)
|
||||
# ---------------------------------------------------------------------------
|
||||
def has_label(work_item_id: str, label_name: str, project_id: str | None = None) -> bool:
|
||||
"""True iff the issue carries a label whose name == ``label_name`` (normalized).
|
||||
|
||||
Resolution (all inside one ``try/except -> False``):
|
||||
1. ``plane_sync.fetch_issue_labels`` — the issue's label uuids (None on error
|
||||
-> False);
|
||||
2. ``plane_sync.get_project_labels`` — {normalized_name -> uuid} project map
|
||||
(TTL-cached);
|
||||
3. normalize the sought name and look it up in the project map;
|
||||
4. no match, OR an ambiguous name (the project map maps it to the
|
||||
``__AMBIGUOUS__`` sentinel) -> False (fail-safe);
|
||||
5. ``return target_uuid in set(labels)``.
|
||||
|
||||
Any error / unavailability / ambiguity -> **False** (never auto on doubt).
|
||||
"""
|
||||
if not label_name:
|
||||
return False
|
||||
try:
|
||||
from . import plane_sync
|
||||
labels = plane_sync.fetch_issue_labels(work_item_id, project_id)
|
||||
if labels is None:
|
||||
# Could not read the issue's labels -> fail-safe to manual.
|
||||
return False
|
||||
if not labels:
|
||||
return False
|
||||
name_map = plane_sync.get_project_labels(
|
||||
plane_sync._resolve_project_id(work_item_id, project_id)
|
||||
)
|
||||
if not name_map:
|
||||
return False
|
||||
normalized = plane_sync._normalize_label(label_name)
|
||||
target_uuid = name_map.get(normalized)
|
||||
if not target_uuid or target_uuid == "__AMBIGUOUS__":
|
||||
return False
|
||||
return target_uuid in set(labels)
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> no auto
|
||||
logger.warning(
|
||||
"has_label error for %s/%s -> fail-safe (no auto): %s",
|
||||
work_item_id, label_name, e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability snapshot for GET /queue (ADR-001 D7)
|
||||
# ---------------------------------------------------------------------------
|
||||
def snapshot() -> dict:
|
||||
"""Read-only auto-label summary for GET /queue (additive block). never-raise."""
|
||||
try:
|
||||
enabled = bool(getattr(settings, "auto_label_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
enabled = False
|
||||
try:
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"approve_label": getattr(settings, "auto_approve_label", ""),
|
||||
"deploy_label": getattr(settings, "auto_deploy_label", ""),
|
||||
"repos": getattr(settings, "auto_label_repos", "") or "",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> minimal dict
|
||||
logger.warning("labels snapshot error: %s", e)
|
||||
return {"enabled": enabled, "approve_label": "", "deploy_label": "", "repos": ""}
|
||||
37
src/main.py
37
src/main.py
@@ -149,6 +149,8 @@ async def queue():
|
||||
from . import post_deploy
|
||||
from . import merge_gate
|
||||
from . import task_deps
|
||||
from . import serial_gate
|
||||
from . import labels
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
@@ -161,5 +163,40 @@ async def queue():
|
||||
# ORCH-026 (G-2): declarative task-dependency observability (read-only,
|
||||
# NOT a source of truth) — declared edges, blocked tasks, detected cycle.
|
||||
"task_deps": task_deps.snapshot(),
|
||||
# ORCH-088 (D9 / AC-10): per-repo serial-gate observability (read-only) —
|
||||
# active task, queued/waiting analyst-jobs, freeze state. Additive block.
|
||||
"serial_gate": serial_gate.snapshot(),
|
||||
# ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch,
|
||||
# label names, scope. Additive block.
|
||||
"auto_labels": labels.snapshot(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/serial-gate/unfreeze")
|
||||
async def serial_gate_unfreeze(repo: str = ""):
|
||||
"""ORCH-088 (FR-5, ADR-001 D4): manually clear a per-repo rollback-freeze.
|
||||
|
||||
A freeze set by the post-deploy monitor (DEGRADED) keeps the serial gate CLOSED
|
||||
for the repo until an operator explicitly clears it here. Idempotent: clearing
|
||||
an already-clear repo reports ``cleared: 0``. The next queued analyst-job is then
|
||||
claimable on the next scheduler tick (no restart needed). Alternative manual path
|
||||
(documented in README): ``UPDATE repo_freeze SET cleared_at=datetime('now')
|
||||
WHERE repo=? AND cleared_at IS NULL``.
|
||||
"""
|
||||
from . import serial_gate
|
||||
if not repo or not repo.strip():
|
||||
return {"ok": False, "error": "missing 'repo'", "repo": repo, "cleared": 0}
|
||||
repo = repo.strip()
|
||||
cleared = serial_gate.clear_repo_freeze(repo)
|
||||
frozen = serial_gate.is_repo_frozen(repo)
|
||||
if cleared:
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
send_telegram(
|
||||
f"🔥 {repo}: пакет РАЗМОРОЖЕН вручную ({cleared} запис(ь/и) снято). "
|
||||
f"Следующая задача репо стартует на ближайшем цикле."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen}
|
||||
|
||||
@@ -326,6 +326,160 @@ def reload_project_states(project_id: str = None) -> None:
|
||||
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-089: label reading (auto-mode by Plane labels) + Approved setter.
|
||||
#
|
||||
# Source of truth for an issue's labels is the Plane API, NOT the webhook payload
|
||||
# (both auto-mode insertion points are launcher-path events where the payload is
|
||||
# absent; src/webhooks/plane.py does not carry `labels`). All three helpers honour
|
||||
# a never-raise contract: a failure degrades to "no label" / "no-op", so the
|
||||
# auto-mode falls back to the manual gate (fail-safe, BR-6/AC-6).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-project label-map cache (mirrors _STATES_CACHE / ORCH-068 TTL self-heal).
|
||||
# Each entry: {"map": {normalized_name -> uuid}, "ts": monotonic timestamp}.
|
||||
_LABELS_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _normalize_label(name: str) -> str:
|
||||
"""Normalize a label name for matching (case/whitespace-insensitive)."""
|
||||
return (name or "").strip().casefold()
|
||||
|
||||
|
||||
def _labels_record_fresh(record: dict) -> bool:
|
||||
"""ORCH-089: is a label-map cache record still within its TTL?
|
||||
|
||||
``auto_label_states_ttl_s <= 0`` disables the TTL (lifetime cache, escape
|
||||
hatch mirroring ``_cache_record_fresh`` / ``plane_states_ttl_s``).
|
||||
"""
|
||||
try:
|
||||
ttl = settings.auto_label_states_ttl_s
|
||||
except Exception: # noqa: BLE001
|
||||
ttl = 0
|
||||
if ttl <= 0:
|
||||
return True
|
||||
ts = record.get("ts", 0.0)
|
||||
return (time.monotonic() - ts) <= ttl
|
||||
|
||||
|
||||
def reload_project_labels(project_id: str = None) -> None:
|
||||
"""ORCH-089: clear the per-project label-map cache (tests / config reload)."""
|
||||
global _LABELS_CACHE
|
||||
if project_id is None:
|
||||
_LABELS_CACHE = {}
|
||||
else:
|
||||
_LABELS_CACHE.pop(project_id, None)
|
||||
|
||||
|
||||
def get_project_labels(project_id: str) -> dict[str, str]:
|
||||
"""ORCH-089: resolve {normalized_label_name -> uuid} for a Plane project.
|
||||
|
||||
Source of truth: GET /projects/<pid>/labels/. Cached per project_id with a
|
||||
TTL (``auto_label_states_ttl_s``, default 300s) mirroring
|
||||
``get_project_states`` so we do not hit the API on every gate. On a transient
|
||||
API failure a stale-but-correct cached map is served (safer-than-empty); with
|
||||
nothing cached -> ``{}`` (caller resolves to "no label" -> manual gate).
|
||||
|
||||
Ambiguity guard (D1.4): if two distinct project labels normalise to the SAME
|
||||
name, that name is mapped to a sentinel so ``has_label`` treats it as "no
|
||||
match" (fail-safe) instead of silently picking one uuid. never-raise -> ``{}``.
|
||||
"""
|
||||
if not project_id:
|
||||
return {}
|
||||
|
||||
cached = _LABELS_CACHE.get(project_id)
|
||||
if cached is not None and _labels_record_fresh(cached):
|
||||
return cached["map"]
|
||||
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/labels/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
items = body.get("results", body) if isinstance(body, dict) else body
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"unexpected labels response shape: {type(items)}")
|
||||
|
||||
name_map: dict[str, str] = {}
|
||||
ambiguous: set[str] = set()
|
||||
for item in items:
|
||||
uid = item.get("id", "")
|
||||
norm = _normalize_label(item.get("name", ""))
|
||||
if not (uid and norm):
|
||||
continue
|
||||
if norm in name_map and name_map[norm] != uid:
|
||||
# Two distinct labels collide on the normalized name -> ambiguous.
|
||||
ambiguous.add(norm)
|
||||
name_map[norm] = uid
|
||||
for norm in ambiguous:
|
||||
# AMBIGUOUS sentinel: never equals a real issue-label uuid, so
|
||||
# has_label's membership test is False -> fail-safe to the manual gate.
|
||||
name_map[norm] = "__AMBIGUOUS__"
|
||||
logger.warning(
|
||||
"get_project_labels: ambiguous label name %r in project %s "
|
||||
"-> treated as no-match (fail-safe)", norm, project_id[:8],
|
||||
)
|
||||
|
||||
_LABELS_CACHE[project_id] = {"map": name_map, "ts": time.monotonic()}
|
||||
logger.debug(
|
||||
"get_project_labels: cached %d labels for project %s...",
|
||||
len(name_map), project_id[:8],
|
||||
)
|
||||
return name_map
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
if cached is not None:
|
||||
logger.warning(
|
||||
"get_project_labels: API refresh failed for project %s..., "
|
||||
"serving stale cached map. Error: %s", project_id[:8], e,
|
||||
)
|
||||
return cached["map"]
|
||||
logger.warning(
|
||||
"get_project_labels: API failed for project %s..., no cache -> {}. "
|
||||
"Error: %s", project_id[:8], e,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_issue_labels(work_item_id: str, project_id: str = None) -> list[str] | None:
|
||||
"""ORCH-089: GET the issue and return its ``labels`` (a list of label uuids).
|
||||
|
||||
Returns ``None`` on any error / issue-not-found (DISTINCT from ``[]`` = "the
|
||||
issue has no labels") so the caller can distinguish "could not read" (fail-safe
|
||||
to manual) from "definitely no labels". never-raise.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
issue_id = find_issue_id(work_item_id, project_id)
|
||||
if not issue_id:
|
||||
logger.debug("fetch_issue_labels: issue not found for %s", work_item_id)
|
||||
return None
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
labels = resp.json().get("labels", [])
|
||||
if not isinstance(labels, list):
|
||||
return None
|
||||
return [str(x) for x in labels]
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("fetch_issue_labels failed for %s: %s", work_item_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def set_issue_approved(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-089: set issue to 'Approved' — indication of an auto-approved BRD.
|
||||
|
||||
1:1 mirror of ``set_issue_in_review``: resolve the per-project Approved UUID
|
||||
(``get_project_states(pid)["approved"]`` — the key already exists in
|
||||
``_DEFAULT_STATES`` / ``_PLANE_NAME_TO_KEY``) and PATCH the issue. never-raise
|
||||
(via ``_set_issue_state_direct``). The status is transient — the immediately
|
||||
following advance to ``architecture`` overrides it; durable transparency is
|
||||
carried by the log + Telegram + Plane comment (AC-7).
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["approved"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
|
||||
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress
|
||||
|
||||
404
src/serial_gate.py
Normal file
404
src/serial_gate.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""ORCH-088 (Этап 1, serial e2e): per-repo serial gate + durable rollback-freeze.
|
||||
|
||||
Leaf module — pure, unit-testable logic over the existing ``tasks`` / ``jobs``
|
||||
tables and the additive ``repo_freeze`` table (see src/db.py /
|
||||
08-data-requirements.md). Mirrors the leaf pattern of ``src/task_deps.py`` /
|
||||
``src/post_deploy.py``: imports only ``db`` + ``config`` (and lazily
|
||||
``projects`` for the snapshot), never ``stage_engine`` / ``launcher``.
|
||||
|
||||
What it enforces (ADR-001):
|
||||
* A NEW task's analyst-job does NOT enter analysis (no branch cut, no analyst
|
||||
agent) while the same repo has ANOTHER unfinished task (``tasks.stage !=
|
||||
'done'``) OR the repo is frozen. The gate is a SQL fragment spliced into
|
||||
``db.claim_next_job`` (offline hot path) — ``build_claim_clause``.
|
||||
* After a post-deploy ``DEGRADED`` verdict the repo is frozen
|
||||
(``set_repo_freeze``); the gate stays CLOSED until an operator clears it
|
||||
(``clear_repo_freeze``). The degraded task is already ``stage='done'`` (BR-7)
|
||||
so freeze is a SEPARATE durable signal, not derived from a stage.
|
||||
|
||||
never-raise contract (self-hosting safety): every public function degrades
|
||||
conservatively and NEVER propagates into the worker / webhook / stage engine.
|
||||
Two deliberately different failure directions (ADR-001 D10, NFR-1):
|
||||
* hot-claim clause build -> fail-OPEN ("" fragment): a transient DB/build error
|
||||
must not wedge the queue of ALL projects (AC-8).
|
||||
* freeze decision (``is_repo_frozen``) -> fail-CLOSED (``True``): when we cannot
|
||||
confirm the ABSENCE of a freeze we keep the gate closed for prod safety (AC-9).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from . import db
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.serial_gate")
|
||||
|
||||
# Repo tokens embedded into the claim SQL ``IN (...)`` list must match this — a
|
||||
# guard against a broken/injected ORCH_SERIAL_GATE_REPOS CSV (R-6). The CSV is an
|
||||
# operator config (not user input), but the guard is mandatory; an invalid token
|
||||
# is silently dropped.
|
||||
_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditionality (mirrors post_deploy_applies / _merge_gate_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _scope_repos() -> set[str]:
|
||||
"""Sanitised set of in-scope repo tokens from ``serial_gate_repos`` (CSV).
|
||||
|
||||
Empty/blank CSV -> empty set, meaning "apply to ALL repos" (D5). Invalid
|
||||
tokens (regex miss) are dropped. Never raises.
|
||||
"""
|
||||
try:
|
||||
raw = (settings.serial_gate_repos or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return set()
|
||||
if not raw:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for tok in raw.split(","):
|
||||
t = tok.strip()
|
||||
if t and _REPO_TOKEN.match(t):
|
||||
out.add(t)
|
||||
elif t:
|
||||
logger.warning("serial_gate: dropping invalid repo token %r from CSV", t)
|
||||
return out
|
||||
|
||||
|
||||
def serial_gate_applies(repo: str) -> bool:
|
||||
"""Whether the serial gate is REAL for this repo (D5 / AC-7).
|
||||
|
||||
* ``serial_gate_enabled=False`` -> always False (kill-switch; claim and
|
||||
start_pipeline are 1:1 as before ORCH-088).
|
||||
* ``serial_gate_repos`` (CSV) non-empty -> real only for listed repos.
|
||||
* empty CSV -> real for ALL repos (serial e2e + anti-stale-base help every
|
||||
repo, unlike the self-hosting-only ORCH-35/43/58 gates).
|
||||
Never raises -> False on error (degrade to "gate inert", the safe-for-flow
|
||||
default that matches the kill-switch off behaviour).
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "serial_gate_enabled", False):
|
||||
return False
|
||||
scope = _scope_repos()
|
||||
if scope:
|
||||
return (repo or "").strip() in scope
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("serial_gate_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def _freeze_layer_enabled() -> bool:
|
||||
"""Whether the FR-5 freeze layer is active (independent tumbler, D7)."""
|
||||
try:
|
||||
return bool(getattr(settings, "serial_gate_freeze_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read helpers (active task + freeze) — only the local DB
|
||||
# ---------------------------------------------------------------------------
|
||||
def repo_has_active_task(repo: str, exclude_task_id: int | None = None) -> bool:
|
||||
"""True iff repo has a task with ``stage != 'done'`` (excluding one task).
|
||||
|
||||
``exclude_task_id`` is the task being evaluated (a new/rework task must not
|
||||
count ITSELF as the active task that blocks it — R-7). Observability/Python
|
||||
mirror of the SQL gate; never raises -> False on error.
|
||||
"""
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
if exclude_task_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done' LIMIT 1",
|
||||
(repo, exclude_task_id),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE repo=? AND stage != 'done' LIMIT 1",
|
||||
(repo,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("repo_has_active_task error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def _active_freeze_row(repo: str) -> dict | None:
|
||||
"""Most-recent active (``cleared_at IS NULL``) freeze row for repo, or None.
|
||||
|
||||
Raises on a real DB error (the caller decides fail-open vs fail-closed) — this
|
||||
private helper does NOT swallow so ``is_repo_frozen`` can fail CLOSED.
|
||||
"""
|
||||
conn = db.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT repo, frozen_at, reason, work_item_id FROM repo_freeze "
|
||||
"WHERE repo=? AND cleared_at IS NULL ORDER BY id DESC LIMIT 1",
|
||||
(repo,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_repo_frozen(repo: str) -> bool:
|
||||
"""True iff repo currently has an active freeze (FR-5).
|
||||
|
||||
fail-CLOSED (AC-9): when the freeze layer is enabled and we CANNOT confirm the
|
||||
absence of a freeze (DB error), return True — keep the gate closed for prod
|
||||
safety. When the freeze layer is disabled the repo is never considered frozen.
|
||||
"""
|
||||
if not _freeze_layer_enabled():
|
||||
return False
|
||||
try:
|
||||
return _active_freeze_row(repo) is not None
|
||||
except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt (AC-9)
|
||||
logger.warning("is_repo_frozen error for %s -> fail-CLOSED (frozen): %s", repo, e)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Freeze mutators (FR-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
def set_repo_freeze(repo: str, reason: str = "", work_item_id: str | None = None) -> bool:
|
||||
"""Insert a durable freeze row for repo (no-op when the freeze layer is off).
|
||||
|
||||
Append-only: a repeated DEGRADED while already frozen simply adds another row
|
||||
(history); ``is_repo_frozen``'s EXISTS is idempotent. Returns True iff a row
|
||||
was inserted. never-raise -> False on error (a freeze write failure must not
|
||||
crash the post-deploy monitor tick).
|
||||
"""
|
||||
if not _freeze_layer_enabled():
|
||||
logger.info("set_repo_freeze: freeze layer disabled, skipping for %s", repo)
|
||||
return False
|
||||
if not repo:
|
||||
return False
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO repo_freeze (repo, reason, work_item_id) VALUES (?, ?, ?)",
|
||||
(repo, reason or None, work_item_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
logger.warning(
|
||||
"serial_gate: repo %s FROZEN (reason=%r, work_item=%s) — next task will "
|
||||
"NOT start until manual unfreeze", repo, reason, work_item_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.error("set_repo_freeze error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def clear_repo_freeze(repo: str) -> int:
|
||||
"""Clear ALL active freeze rows for repo (operator unfreeze, D4).
|
||||
|
||||
Sets ``cleared_at=now`` on every row with ``cleared_at IS NULL``. Idempotent
|
||||
(a repeat clears 0 rows). Returns the number of rows cleared. never-raise -> 0
|
||||
on error.
|
||||
"""
|
||||
if not repo:
|
||||
return 0
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE repo_freeze SET cleared_at=datetime('now') "
|
||||
"WHERE repo=? AND cleared_at IS NULL",
|
||||
(repo,),
|
||||
)
|
||||
conn.commit()
|
||||
n = cur.rowcount or 0
|
||||
finally:
|
||||
conn.close()
|
||||
if n:
|
||||
logger.warning("serial_gate: repo %s UNFROZEN (%d row(s) cleared)", repo, n)
|
||||
return n
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.error("clear_repo_freeze error for %s: %s", repo, e)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hot-claim SQL fragment (fail-OPEN) — ADR-001 D1
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_claim_clause() -> str:
|
||||
"""Build the ``AND NOT (...)`` fragment spliced into ``claim_next_job``.
|
||||
|
||||
Blocks an analyst-job whose repo (a) has an EARLIER-queued unfinished task or
|
||||
(b) is frozen. Only ``jobs.agent='analyst'`` is gated — jobs of an
|
||||
already-active task pass freely (else the single active task could never
|
||||
advance).
|
||||
|
||||
Ordering term — ``t2.id < jobs.task_id`` (FIFO, ADR-001 D1 / FR-2): a task is
|
||||
blocked only by EARLIER tasks (lower ``tasks.id``) that are not yet done. This
|
||||
is the FIFO refinement of the ADR's pseudo-SQL ``t2.id != jobs.task_id``: with
|
||||
``!=`` a BATCH of fresh tasks all sitting in ``analysis`` would mutually block
|
||||
(each is "another unfinished task" for the others) -> the whole serial queue
|
||||
deadlocks, contradicting FR-2 ("строго по одной, FIFO по jobs.id"). ``<`` admits
|
||||
exactly the oldest unfinished task and serialises the rest behind it, while
|
||||
still never self-blocking a new/rework analyst-job on its OWN row (R-7) and
|
||||
keeping AC-1 (a newer task is held by the older active one) intact.
|
||||
|
||||
Repo scope: empty CSV -> no repo filter (all repos); non-empty CSV -> ``AND
|
||||
jobs.repo IN ('a','b')`` with sanitised tokens (R-6).
|
||||
|
||||
fail-OPEN (AC-8): kill-switch off OR any build error -> ``""`` (claim behaves
|
||||
exactly as before ORCH-088). The trailing space keeps the spliced SQL valid.
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "serial_gate_enabled", False):
|
||||
return ""
|
||||
scope = _scope_repos()
|
||||
if scope:
|
||||
# All tokens already passed the _REPO_TOKEN regex -> safe to embed.
|
||||
repo_in = ", ".join(f"'{t}'" for t in sorted(scope))
|
||||
repo_scope = f"AND jobs.repo IN ({repo_in}) "
|
||||
else:
|
||||
repo_scope = ""
|
||||
active_clause = (
|
||||
"EXISTS (SELECT 1 FROM tasks t2 "
|
||||
"WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id "
|
||||
"AND t2.stage != 'done') "
|
||||
)
|
||||
if _freeze_layer_enabled():
|
||||
freeze_clause = (
|
||||
"OR EXISTS (SELECT 1 FROM repo_freeze f "
|
||||
"WHERE f.repo = jobs.repo AND f.cleared_at IS NULL) "
|
||||
)
|
||||
else:
|
||||
freeze_clause = ""
|
||||
return (
|
||||
"AND NOT ( jobs.agent = 'analyst' "
|
||||
f"{repo_scope}"
|
||||
f"AND ( {active_clause}{freeze_clause}) "
|
||||
") "
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - fail-OPEN: never wedge the queue
|
||||
logger.warning("build_claim_clause error -> fail-OPEN (no gate): %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability snapshot for GET /queue (D9 / AC-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _known_repos() -> list[str]:
|
||||
"""Registered repo names (best-effort) plus any repo with live gate state."""
|
||||
repos: set[str] = set()
|
||||
try:
|
||||
from . import projects
|
||||
for p in projects.PROJECTS:
|
||||
if getattr(p, "repo", None):
|
||||
repos.add(p.repo)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# Also surface repos that have an active freeze or a queued analyst-job even if
|
||||
# they are not in the static registry (defensive — never hide a frozen repo).
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
for (r,) in conn.execute(
|
||||
"SELECT DISTINCT repo FROM repo_freeze WHERE cleared_at IS NULL"
|
||||
).fetchall():
|
||||
if r:
|
||||
repos.add(r)
|
||||
for (r,) in conn.execute(
|
||||
"SELECT DISTINCT repo FROM jobs WHERE status='queued' AND agent='analyst'"
|
||||
).fetchall():
|
||||
if r:
|
||||
repos.add(r)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return sorted(repos)
|
||||
|
||||
|
||||
def _per_repo_snapshot(repo: str) -> dict:
|
||||
"""Per-repo gate state for the /queue snapshot (never raises here)."""
|
||||
active_task = None
|
||||
waiting: list[dict] = []
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT work_item_id, stage FROM tasks "
|
||||
"WHERE repo=? AND stage != 'done' ORDER BY id LIMIT 1",
|
||||
(repo,),
|
||||
).fetchone()
|
||||
if row:
|
||||
active_task = {"work_item_id": row["work_item_id"], "stage": row["stage"]}
|
||||
for j in conn.execute(
|
||||
"SELECT j.id AS job_id, t.work_item_id AS work_item_id, t.stage AS stage "
|
||||
"FROM jobs j LEFT JOIN tasks t ON t.id = j.task_id "
|
||||
"WHERE j.repo=? AND j.status='queued' AND j.agent='analyst' "
|
||||
"ORDER BY j.id",
|
||||
(repo,),
|
||||
).fetchall():
|
||||
waiting.append({
|
||||
"job_id": j["job_id"],
|
||||
"work_item_id": j["work_item_id"],
|
||||
"stage": j["stage"],
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("serial_gate per-repo snapshot error for %s: %s", repo, e)
|
||||
frozen = is_repo_frozen(repo)
|
||||
frozen_reason = None
|
||||
frozen_at = None
|
||||
if frozen:
|
||||
try:
|
||||
fr = _active_freeze_row(repo)
|
||||
if fr:
|
||||
frozen_reason = fr.get("reason")
|
||||
frozen_at = fr.get("frozen_at")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {
|
||||
"active_task": active_task,
|
||||
"waiting": waiting,
|
||||
"frozen": frozen,
|
||||
"frozen_reason": frozen_reason,
|
||||
"frozen_at": frozen_at,
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Read-only serial-gate summary for GET /queue (D9 / AC-10).
|
||||
|
||||
Additive block; existing /queue keys are untouched. never-raise: any error ->
|
||||
a minimal dict with the flags and empty per-repo data.
|
||||
"""
|
||||
try:
|
||||
enabled = bool(getattr(settings, "serial_gate_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
enabled = False
|
||||
try:
|
||||
repos_cfg = getattr(settings, "serial_gate_repos", "") or ""
|
||||
except Exception: # noqa: BLE001
|
||||
repos_cfg = ""
|
||||
try:
|
||||
per_repo = {r: _per_repo_snapshot(r) for r in _known_repos()}
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"freeze_enabled": _freeze_layer_enabled(),
|
||||
"repos": repos_cfg,
|
||||
"per_repo": per_repo,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> minimal dict
|
||||
logger.warning("serial_gate snapshot error: %s", e)
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"freeze_enabled": False,
|
||||
"repos": repos_cfg,
|
||||
"per_repo": {},
|
||||
}
|
||||
@@ -39,6 +39,7 @@ from .qg.checks import QG_CHECKS
|
||||
from . import merge_gate
|
||||
from . import self_deploy
|
||||
from . import post_deploy
|
||||
from . import labels
|
||||
from .notifications import (
|
||||
notify_stage_change,
|
||||
notify_qg_failure,
|
||||
@@ -59,6 +60,7 @@ from .plane_sync import (
|
||||
set_issue_awaiting_deploy,
|
||||
set_issue_deploying,
|
||||
set_issue_monitoring,
|
||||
set_issue_approved,
|
||||
)
|
||||
from .config import settings
|
||||
|
||||
@@ -596,6 +598,47 @@ def _handle_analysis_approved_flow(
|
||||
logger.info(
|
||||
f"Task {task_id}: analyst finished, requested Approved status in Plane"
|
||||
)
|
||||
|
||||
# --- ORCH-089 autoApprove: auto-pass the BRD human gate by label --------
|
||||
# After In Review + the analyst comment + the approve-request (kept for the
|
||||
# BRD-review clock, transparency and symmetry with the manual path), if the
|
||||
# issue carries the autoApprove label AND the repo is in scope, auto-advance
|
||||
# via the SAME path a human Approved takes — never duplicating the
|
||||
# transition logic. applies() (local, network-free) is checked FIRST so a
|
||||
# disabled kill-switch / out-of-scope repo costs zero network (AC-8); any
|
||||
# error / no-label -> fall through to the prior behaviour (return, wait for
|
||||
# a human, AC-4/AC-6).
|
||||
if labels.auto_approve_applies(repo) and labels.has_label(
|
||||
work_item_id, settings.auto_approve_label
|
||||
):
|
||||
set_issue_approved(work_item_id) # indication (AC-1), transient
|
||||
logger.info(
|
||||
f"Task {task_id}: label {settings.auto_approve_label} -> "
|
||||
f"BRD auto-approved (analysis -> architecture)"
|
||||
)
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
f"✅ BRD авто-подтверждён (лейбл {settings.auto_approve_label}). "
|
||||
"Переход на architecture без ручного Approved.",
|
||||
author="analyst",
|
||||
)
|
||||
send_telegram(
|
||||
f"✅ {link_for(work_item_id)}: BRD авто-подтверждён "
|
||||
f"(лейбл {settings.auto_approve_label})."
|
||||
)
|
||||
# Same advance the human Approved webhook uses: finished_agent=None ->
|
||||
# check_analysis_approved approved-via-status -> advance analysis ->
|
||||
# architecture + mark_brd_review_ended (clock) + standard post-effects.
|
||||
# Re-entrancy is safe: the nested call passes finished_agent=None, so it
|
||||
# does NOT re-enter this analyst branch (which requires agent=='analyst').
|
||||
auto = advance_stage(
|
||||
task_id, current_stage, repo, work_item_id, branch, finished_agent=None
|
||||
)
|
||||
result.advanced = auto.advanced
|
||||
result.to_stage = auto.to_stage
|
||||
result.enqueued_agent = auto.enqueued_agent
|
||||
result.enqueued_job_id = auto.enqueued_job_id
|
||||
result.note = "auto-approved-via-label"
|
||||
return
|
||||
|
||||
questions_path = os.path.join(
|
||||
@@ -1179,6 +1222,40 @@ def _handle_self_deploy_phase_a(
|
||||
# (e.g. after a crash/manual intervention), so `initiated`/`result` from an
|
||||
# earlier attempt can never leak into this one.
|
||||
self_deploy.clear_state(repo, work_item_id)
|
||||
|
||||
# --- ORCH-089 autoDeploy: auto-confirm the prod-deploy human gate by label --
|
||||
# After advancing onto `deploy` + wiping stale markers and BEFORE the ask-human
|
||||
# block, if the issue carries the autoDeploy label AND the repo is in scope,
|
||||
# initiate Phase B via the SAME path a human Confirm Deploy takes. Only the
|
||||
# indicative human steps are skipped (APPROVE_REQUESTED marker +
|
||||
# set_issue_awaiting_deploy + the "flip to Confirm Deploy" comment/Telegram) —
|
||||
# status Deploying is set by Phase B itself. BR-5/AC-5 hold STRUCTURALLY: Phase A
|
||||
# is reached ONLY after the green edge sub-gates (security -> merge-gate ->
|
||||
# image-freshness -> staging), so autoDeploy cannot deploy a broken build.
|
||||
# Idempotency is the existing INITIATED marker inside _handle_self_deploy_phase_b.
|
||||
# applies() FIRST (network-free); any error / no-label -> the prior Phase A
|
||||
# ask-human flow (AC-4/AC-6).
|
||||
if labels.auto_deploy_applies(repo) and labels.has_label(
|
||||
work_item_id, settings.auto_deploy_label
|
||||
):
|
||||
logger.info(
|
||||
f"Task {task_id}: label {settings.auto_deploy_label} -> "
|
||||
f"prod deploy auto-confirmed (Phase B without manual Confirm Deploy)"
|
||||
)
|
||||
if work_item_id:
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
f"🚀 Прод-деплой авто-подтверждён (лейбл {settings.auto_deploy_label}). "
|
||||
"Запуск Phase B без ручного «Confirm Deploy».",
|
||||
author="deployer",
|
||||
)
|
||||
send_telegram(
|
||||
f"🚀 {link_for(work_item_id)}: прод-деплой авто-подтверждён "
|
||||
f"(лейбл {settings.auto_deploy_label})."
|
||||
)
|
||||
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
|
||||
return
|
||||
|
||||
self_deploy.write_marker(
|
||||
repo, work_item_id, self_deploy.APPROVE_REQUESTED, content=str(time.time())
|
||||
)
|
||||
@@ -1708,6 +1785,25 @@ def run_post_deploy_monitor(job: dict):
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}")
|
||||
|
||||
# ORCH-088 (FR-5, ADR-001 D3): durable per-repo rollback-freeze. The degraded
|
||||
# task is already stage='done' (BR-7), so the ordinary active-task gate would
|
||||
# NOT hold the next task — we need a separate durable signal. Freeze the repo so
|
||||
# the serial gate stays CLOSED until an operator clears it (POST
|
||||
# /serial-gate/unfreeze). never-raise (set_repo_freeze swallows its own errors);
|
||||
# the freeze is a PASSIVE start-block, it does NOT touch the prod container (NFR-6).
|
||||
try:
|
||||
from . import serial_gate
|
||||
reason = f"post-deploy DEGRADED ({checks_failed}/{checks_total}) action={action_taken}"
|
||||
if serial_gate.set_repo_freeze(repo, reason, work_item_id):
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"🧊 {repo}: пакет ЗАМОРОЖЕН после пост-деплой DEGRADED "
|
||||
f"({work_item_id}). Следующая задача репо НЕ стартует до ручного "
|
||||
f"снятия: POST /serial-gate/unfreeze?repo={repo}.",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set_repo_freeze 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,
|
||||
|
||||
@@ -573,20 +573,36 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
return
|
||||
task_id = task_row["id"]
|
||||
|
||||
# Create branch in Gitea
|
||||
try:
|
||||
await _create_gitea_branch(repo, branch)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create branch '{branch}': {e}")
|
||||
# Task is created, branch creation failed — log but don't crash
|
||||
notify_error(0, f"Branch creation failed: {e}")
|
||||
return
|
||||
# ORCH-088 (FR-1/AC-6, ADR-001 D1): DEFER the branch cut for an applicable repo.
|
||||
# Creating the Gitea branch here (T0, issue -> analysis) would cut it from `main`
|
||||
# BEFORE the predecessor is merged -> stale base. When the serial gate applies we
|
||||
# do NOT create the branch / initial docs now; the analyst-job sits in the queue
|
||||
# (status='queued', no branch) and the gate keeps it there until the predecessor
|
||||
# reaches stage='done'. The branch + docs are then materialised at claim time in
|
||||
# launcher._spawn from a fresh origin/main (anti-stale-base). The task row already
|
||||
# stores `branch` as a NAME (R-5) — only the git ref is deferred.
|
||||
from .. import serial_gate
|
||||
defer_branch = serial_gate.serial_gate_applies(repo)
|
||||
if not defer_branch:
|
||||
# Create branch in Gitea
|
||||
try:
|
||||
await _create_gitea_branch(repo, branch)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create branch '{branch}': {e}")
|
||||
# Task is created, branch creation failed — log but don't crash
|
||||
notify_error(0, f"Branch creation failed: {e}")
|
||||
return
|
||||
|
||||
# Create initial docs structure via Gitea API (create file)
|
||||
try:
|
||||
await _create_initial_docs(repo, branch, work_item_id, name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create initial docs: {e}")
|
||||
# Create initial docs structure via Gitea API (create file)
|
||||
try:
|
||||
await _create_initial_docs(repo, branch, work_item_id, name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create initial docs: {e}")
|
||||
else:
|
||||
logger.info(
|
||||
f"Task {work_item_id}: serial gate applies for {repo} -> deferring branch "
|
||||
f"cut to analyst-job claim (anti-stale-base, ORCH-088)"
|
||||
)
|
||||
|
||||
logger.info(f"Task created: {work_item_id} ({name}), branch={branch}, stage=analysis")
|
||||
|
||||
|
||||
190
tests/test_auto_approve_brd.py
Normal file
190
tests/test_auto_approve_brd.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""ORCH-089 — autoApprove врезка in _handle_analysis_approved_flow.
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-10 autoApprove + artifacts ready -> auto-advance analysis->architecture,
|
||||
Approved set, brd_review_ended clock closed.
|
||||
TC-11 no autoApprove label -> prior behaviour: In Review, return w/o advance.
|
||||
TC-12 autoApprove but artifacts missing (check_analysis_complete FAIL) -> NO
|
||||
advance (AC-5 for BRD).
|
||||
TC-13 autoApprove goes through the SAME advance path as a manual Approved (no
|
||||
duplicated transition logic; idempotent — stage lands on architecture).
|
||||
TC-14 autoApprove logged + Telegram + Plane comment (transparency AC-7).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_approve_brd.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _files_ok(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
def _files_fail(*a, **k):
|
||||
return (False, "missing artifacts")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
# Silence Plane/Telegram side effects; capture the transparency channels.
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||
"set_issue_approved", "notify_approve_requested"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
# Avoid worktree access in the analyst "ready" comment builder.
|
||||
monkeypatch.setattr(stage_engine, "_build_analyst_ready_comment",
|
||||
lambda *a, **k: "ready", raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(stage="analysis", repo="orchestrator", branch="feature/ORCH-089-x",
|
||||
wi="ORCH-089"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _stage_of(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _brd_ended(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT brd_review_ended_at FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _patch_complete_gate(monkeypatch, ok=True):
|
||||
gate = _files_ok if ok else _files_fail
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_analysis_complete": gate},
|
||||
)
|
||||
|
||||
|
||||
def _label(monkeypatch, present=True, applies=True):
|
||||
monkeypatch.setattr(labels, "auto_approve_applies", lambda repo: applies)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: present)
|
||||
|
||||
|
||||
# --- TC-10 -----------------------------------------------------------------
|
||||
def test_tc10_auto_approve_advances(monkeypatch):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
# The BRD-review clock was started when the task entered In Review; the
|
||||
# advance closes it (mark_brd_review_ended only stamps when a start exists).
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE tasks SET brd_review_started_at=datetime('now') WHERE id=?", (tid,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res.note == "auto-approved-via-label"
|
||||
assert res.advanced is True
|
||||
assert _stage_of(tid) == "architecture"
|
||||
assert _brd_ended(tid) is not None # clock closed by mark_brd_review_ended
|
||||
stage_engine.set_issue_approved.assert_called_once() # Approved indication
|
||||
|
||||
|
||||
# --- TC-11 -----------------------------------------------------------------
|
||||
def test_tc11_no_label_waits_for_human(monkeypatch):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=False)
|
||||
tid = _make_task()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res.note == "analysis-in-review"
|
||||
assert res.advanced is False
|
||||
assert _stage_of(tid) == "analysis" # still waiting for a human
|
||||
stage_engine.set_issue_in_review.assert_called_once()
|
||||
stage_engine.set_issue_approved.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-12 -----------------------------------------------------------------
|
||||
def test_tc12_missing_artifacts_no_auto(monkeypatch):
|
||||
# autoApprove present, but artifacts incomplete -> files_ok False -> the
|
||||
# autoApprove block (inside `if files_ok`) is never reached.
|
||||
_patch_complete_gate(monkeypatch, ok=False)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res.advanced is False
|
||||
assert _stage_of(tid) == "analysis"
|
||||
assert res.note != "auto-approved-via-label"
|
||||
stage_engine.set_issue_approved.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-13: same advance path / idempotent ---------------------------------
|
||||
def test_tc13_same_advance_path_idempotent(monkeypatch):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
# The advance went through the unified path -> architect enqueued exactly once.
|
||||
assert res.enqueued_agent == "architect"
|
||||
conn = get_db()
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND agent='architect'", (tid,)
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert n == 1
|
||||
# A later real Approved (webhook path, finished_agent=None) sees architecture,
|
||||
# not analysis -> it cannot re-run the analysis advance (idempotent).
|
||||
assert _stage_of(tid) == "architecture"
|
||||
|
||||
|
||||
# --- TC-14: transparency ---------------------------------------------------
|
||||
def test_tc14_transparency_channels(monkeypatch, caplog):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="orchestrator.stage_engine"):
|
||||
advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
# (a) log mentions the label + auto-approve.
|
||||
assert any("auto-approved" in r.message.lower() or "autoApprove" in r.message
|
||||
for r in caplog.records)
|
||||
# (b) Telegram fired; (c) a Plane comment authored by analyst about the auto-pass.
|
||||
assert stage_engine.send_telegram.called
|
||||
comment_calls = [c for c in stage_engine.plane_add_comment.call_args_list
|
||||
if "авто-подтверждён" in c.args[1]]
|
||||
assert comment_calls, "expected an auto-approve Plane comment"
|
||||
182
tests/test_auto_deploy.py
Normal file
182
tests/test_auto_deploy.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""ORCH-089 — autoDeploy врезка in _handle_self_deploy_phase_a.
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-15 autoDeploy + Phase A advance on `deploy` -> Phase B (initiate_deploy)
|
||||
is auto-invoked.
|
||||
TC-16 no autoDeploy label -> prior Phase A: Awaiting Deploy, wait for Confirm
|
||||
Deploy.
|
||||
TC-17 idempotent: INITIATED marker already present -> repeat auto-trigger no-op.
|
||||
TC-18 non-self repo / out of scope -> no auto (Phase A/B only for self-hosting).
|
||||
TC-19 autoDeploy logged + Telegram + Plane comment (transparency AC-7).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_deploy.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
# Pass all edge sub-gates so the deploy-staging -> deploy edge reaches Phase A.
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
# Default auto-mode flags ON (overridden per-test).
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence(monkeypatch):
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_awaiting_deploy",
|
||||
"set_issue_deploying"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
|
||||
|
||||
def _make_task(stage="deploy-staging", repo="orchestrator",
|
||||
branch="feature/ORCH-089-x", wi="ORCH-089"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _label(monkeypatch, present=True, applies=True):
|
||||
monkeypatch.setattr(labels, "auto_deploy_applies", lambda repo: applies)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: present)
|
||||
|
||||
|
||||
def _advance(tid, repo="orchestrator", wi="ORCH-089"):
|
||||
return advance_stage(tid, "deploy-staging", repo, wi,
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
|
||||
|
||||
# --- TC-15 -----------------------------------------------------------------
|
||||
def test_tc15_auto_deploy_initiates_phase_b(monkeypatch):
|
||||
_label(monkeypatch, present=True)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task()
|
||||
res = _advance(tid)
|
||||
# Phase B ran via the same path a human Confirm Deploy takes.
|
||||
initiate.assert_called_once()
|
||||
assert res.note == "self-deploy-initiated"
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-089", self_deploy.INITIATED)
|
||||
# APPROVE_REQUESTED (the human ask) was SKIPPED on the auto path.
|
||||
assert not self_deploy.has_marker(
|
||||
"orchestrator", "ORCH-089", self_deploy.APPROVE_REQUESTED
|
||||
)
|
||||
|
||||
|
||||
# --- TC-16 -----------------------------------------------------------------
|
||||
def test_tc16_no_label_waits_for_human(monkeypatch):
|
||||
_label(monkeypatch, present=False)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task()
|
||||
res = _advance(tid)
|
||||
# Prior Phase A behaviour: approval-pending, no deploy initiated.
|
||||
assert res.note == "self-deploy-approval-pending"
|
||||
initiate.assert_not_called()
|
||||
assert self_deploy.has_marker(
|
||||
"orchestrator", "ORCH-089", self_deploy.APPROVE_REQUESTED
|
||||
)
|
||||
stage_engine.set_issue_awaiting_deploy.assert_called_once()
|
||||
|
||||
|
||||
# --- TC-17: idempotency ----------------------------------------------------
|
||||
def test_tc17_idempotent_initiated_marker(monkeypatch):
|
||||
"""autoDeploy delegates prod-deploy to _handle_self_deploy_phase_b, whose
|
||||
INITIATED marker makes a repeat a no-op. Phase A always clears stale state
|
||||
first (ADR D4), so the guard that protects against a double prod deploy is the
|
||||
INITIATED marker WRITTEN by Phase B — verify the auto path initiates exactly
|
||||
once and a subsequent Phase B re-entry (duplicate confirm / reaper re-drive) is
|
||||
a no-op."""
|
||||
_label(monkeypatch, present=True)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task()
|
||||
res = _advance(tid)
|
||||
assert res.note == "self-deploy-initiated"
|
||||
assert initiate.call_count == 1
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-089", self_deploy.INITIATED)
|
||||
# A repeat Phase B (e.g. duplicate Confirm Deploy webhook / reaper re-drive)
|
||||
# with INITIATED already set is a no-op — no second prod deploy.
|
||||
res2 = stage_engine._handle_self_deploy_phase_b(
|
||||
tid, "orchestrator", "ORCH-089", "feature/ORCH-089-x",
|
||||
stage_engine.AdvanceResult(from_stage="deploy"),
|
||||
)
|
||||
assert initiate.call_count == 1 # still exactly one
|
||||
|
||||
|
||||
# --- TC-18: non-self / out of scope ----------------------------------------
|
||||
def test_tc18_non_self_repo_no_phase_a(monkeypatch):
|
||||
# For a non-self repo Phase A is not reached at all (self_deploy_applies False),
|
||||
# so autoDeploy is a structural no-op. The edge advances normally to `deploy`.
|
||||
_label(monkeypatch, present=True, applies=False)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task(repo="enduro-trails", wi="ET-1")
|
||||
res = advance_stage(tid, "deploy-staging", "enduro-trails", "ET-1",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
initiate.assert_not_called()
|
||||
# No Phase A / Phase B for non-self repo.
|
||||
assert res.note != "self-deploy-initiated"
|
||||
|
||||
|
||||
# --- TC-19: transparency ---------------------------------------------------
|
||||
def test_tc19_transparency_channels(monkeypatch, caplog):
|
||||
_label(monkeypatch, present=True)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy",
|
||||
MagicMock(return_value=(True, "ok")))
|
||||
tid = _make_task()
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="orchestrator.stage_engine"):
|
||||
_advance(tid)
|
||||
assert any("auto-confirmed" in r.message.lower() or "autoDeploy" in r.message
|
||||
for r in caplog.records)
|
||||
assert stage_engine.send_telegram.called
|
||||
comment_calls = [c for c in stage_engine.plane_add_comment.call_args_list
|
||||
if "авто-подтверждён" in c.args[1]]
|
||||
assert comment_calls, "expected an auto-deploy Plane comment"
|
||||
146
tests/test_auto_label_combinations.py
Normal file
146
tests/test_auto_label_combinations.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""ORCH-089 — label independence + kill-switch (AC-8/AC-9).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-20 only autoApprove: BRD auto, deploy waits for a human (AC-9).
|
||||
TC-21 only autoDeploy: BRD waits for a human, deploy auto (AC-9).
|
||||
TC-22 auto_label_enabled=False: both gates manual even with both labels (AC-8).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_combos.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_analysis_complete": _pass,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine, "_build_analyst_ready_comment",
|
||||
lambda *a, **k: "ready", raising=False)
|
||||
# Real auto-mode scope/flags (kill-switch exercised per-test).
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_repos", "", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_approve_label", "autoApprove", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy",
|
||||
MagicMock(return_value=(True, "ok")))
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_approved",
|
||||
"notify_approve_requested"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-089-x", wi="ORCH-089"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, brd_review_started_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _stage_of(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _present_labels(monkeypatch, names):
|
||||
"""has_label True only for the given normalized label names (real applies())."""
|
||||
want = {n.casefold() for n in names}
|
||||
monkeypatch.setattr(labels, "has_label",
|
||||
lambda w, name, p=None: name.casefold() in want)
|
||||
|
||||
|
||||
def _run_brd(wi="ORCH-089"):
|
||||
tid = _make_task("analysis", wi=wi)
|
||||
res = advance_stage(tid, "analysis", "orchestrator", wi,
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
return tid, res
|
||||
|
||||
|
||||
def _run_deploy(wi="ORCH-089"):
|
||||
tid = _make_task("deploy-staging", wi=wi)
|
||||
res = advance_stage(tid, "deploy-staging", "orchestrator", wi,
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
return tid, res
|
||||
|
||||
|
||||
# --- TC-20: only autoApprove -----------------------------------------------
|
||||
def test_tc20_only_auto_approve(monkeypatch):
|
||||
_present_labels(monkeypatch, ["autoApprove"])
|
||||
tid_brd, res_brd = _run_brd()
|
||||
assert res_brd.note == "auto-approved-via-label"
|
||||
assert _stage_of(tid_brd) == "architecture"
|
||||
# Deploy gate still manual (autoDeploy absent).
|
||||
_tid_dep, res_dep = _run_deploy()
|
||||
assert res_dep.note == "self-deploy-approval-pending"
|
||||
|
||||
|
||||
# --- TC-21: only autoDeploy ------------------------------------------------
|
||||
def test_tc21_only_auto_deploy(monkeypatch):
|
||||
_present_labels(monkeypatch, ["autoDeploy"])
|
||||
tid_brd, res_brd = _run_brd()
|
||||
# BRD gate still manual (autoApprove absent).
|
||||
assert res_brd.note == "analysis-in-review"
|
||||
assert _stage_of(tid_brd) == "analysis"
|
||||
# Deploy auto-confirmed.
|
||||
_tid_dep, res_dep = _run_deploy()
|
||||
assert res_dep.note == "self-deploy-initiated"
|
||||
|
||||
|
||||
# --- TC-22: kill-switch -> both manual -------------------------------------
|
||||
def test_tc22_killswitch_both_manual(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", False, raising=False)
|
||||
# Both labels "present", but the kill-switch makes applies() False FIRST, so
|
||||
# has_label is never consulted -> both gates stay manual.
|
||||
spy = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(labels, "has_label", spy)
|
||||
tid_brd, res_brd = _run_brd()
|
||||
assert res_brd.note == "analysis-in-review"
|
||||
assert _stage_of(tid_brd) == "analysis"
|
||||
_tid_dep, res_dep = _run_deploy()
|
||||
assert res_dep.note == "self-deploy-approval-pending"
|
||||
spy.assert_not_called() # zero network — AC-8
|
||||
147
tests/test_auto_labels_integration.py
Normal file
147
tests/test_auto_labels_integration.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""ORCH-089 — integration: end-to-end auto-pass across pipeline edges.
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-23 both labels + all tech-gates green -> analysis -> deploy with no manual
|
||||
clicks (AC-3).
|
||||
TC-24 autoDeploy + a RED edge sub-gate -> Phase A not reached, Phase B not
|
||||
initiated (AC-5).
|
||||
TC-25 regression: no labels -> both gates manual exactly as before ORCH-089
|
||||
(AC-4).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_integ.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_repos", "", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_approve_label", "autoApprove", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
monkeypatch.setattr(stage_engine, "_build_analyst_ready_comment",
|
||||
lambda *a, **k: "ready", raising=False)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy",
|
||||
MagicMock(return_value=(True, "ok")))
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_approved",
|
||||
"set_issue_blocked", "notify_approve_requested"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
yield
|
||||
|
||||
|
||||
def _gates(monkeypatch, **overrides):
|
||||
base = {
|
||||
"check_analysis_complete": _pass,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_staging_image_fresh": _pass,
|
||||
}
|
||||
base.update(overrides)
|
||||
monkeypatch.setattr(stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, **base})
|
||||
|
||||
|
||||
def _make_task(stage, wi="ORCH-089", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, brd_review_started_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(f"plane-{wi}", wi, repo, "feature/ORCH-089-x", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _stage_of(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
# --- TC-23: both labels, all green -> autonomous ---------------------------
|
||||
def test_tc23_both_labels_autonomous(monkeypatch):
|
||||
_gates(monkeypatch)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: True)
|
||||
|
||||
# BRD edge: analyst finished -> auto-approve -> architecture.
|
||||
brd = _make_task("analysis")
|
||||
res_brd = advance_stage(brd, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res_brd.note == "auto-approved-via-label"
|
||||
assert _stage_of(brd) == "architecture"
|
||||
|
||||
# Deploy edge: staging-deployer finished -> Phase A advances to deploy -> auto
|
||||
# Phase B initiates the prod deploy. No human Approved nor Confirm Deploy.
|
||||
dep = _make_task("deploy-staging", wi="ORCH-089b")
|
||||
res_dep = advance_stage(dep, "deploy-staging", "orchestrator", "ORCH-089b",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
assert res_dep.note == "self-deploy-initiated"
|
||||
assert stage_engine.self_deploy.initiate_deploy.called
|
||||
assert _stage_of(dep) == "deploy"
|
||||
|
||||
|
||||
# --- TC-24: red sub-gate blocks autoDeploy ---------------------------------
|
||||
def test_tc24_red_staging_blocks_auto_deploy(monkeypatch):
|
||||
# staging RED -> the edge fails BEFORE Phase A -> autoDeploy cannot fire.
|
||||
_gates(monkeypatch, check_staging_status=lambda *a, **k: (False, "FAILED"))
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: True)
|
||||
|
||||
dep = _make_task("deploy-staging")
|
||||
res = advance_stage(dep, "deploy-staging", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
# Phase B never initiated despite the autoDeploy label.
|
||||
assert not stage_engine.self_deploy.initiate_deploy.called
|
||||
assert res.note != "self-deploy-initiated"
|
||||
assert _stage_of(dep) != "deploy" # did not advance onto deploy
|
||||
|
||||
|
||||
# --- TC-25: regression — no labels -> manual gates -------------------------
|
||||
def test_tc25_no_labels_manual(monkeypatch):
|
||||
_gates(monkeypatch)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: False)
|
||||
|
||||
brd = _make_task("analysis")
|
||||
res_brd = advance_stage(brd, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res_brd.note == "analysis-in-review"
|
||||
assert _stage_of(brd) == "analysis"
|
||||
|
||||
dep = _make_task("deploy-staging", wi="ORCH-089b")
|
||||
res_dep = advance_stage(dep, "deploy-staging", "orchestrator", "ORCH-089b",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
assert res_dep.note == "self-deploy-approval-pending"
|
||||
assert not stage_engine.self_deploy.initiate_deploy.called
|
||||
33
tests/test_auto_labels_invariants.py
Normal file
33
tests/test_auto_labels_invariants.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""ORCH-089 — TC-26: invariant registries are NOT touched by the auto-label work.
|
||||
|
||||
The auto-mode reuses existing transitions/gates and only removes the wait for a
|
||||
human signal; it must not add a stage, a transition, or a QG check (TRZ §10 / AC-10).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_auto_inv.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
|
||||
def test_tc26_stage_transitions_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done",
|
||||
}
|
||||
# The two human gates still use their existing QG names (unchanged).
|
||||
assert STAGE_TRANSITIONS["analysis"]["qg"] == "check_analysis_approved"
|
||||
|
||||
|
||||
def test_tc26_no_new_qg_check():
|
||||
from src.qg.checks import QG_CHECKS
|
||||
# No auto-label / auto-approve / auto-deploy QG check was introduced — the
|
||||
# auto-mode is a decision врезка, not a registered quality gate.
|
||||
assert not any(
|
||||
tok in k for k in QG_CHECKS for tok in ("auto_label", "autoapprove", "autodeploy")
|
||||
), "ORCH-089 must not register a new QG check"
|
||||
# The gates it reuses are present and untouched.
|
||||
for k in ("check_analysis_approved", "check_deploy_status", "check_staging_status"):
|
||||
assert k in QG_CHECKS
|
||||
150
tests/test_labels.py
Normal file
150
tests/test_labels.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""ORCH-089 — src/labels.py: auto-mode pure logic (never-raise, fail-safe).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-01 has_label True when the label is present on the issue.
|
||||
TC-02 has_label False when the label is absent.
|
||||
TC-03 has_label fail-safe (no auto, never-raise) on Plane API error/timeout.
|
||||
TC-04 label-name matching is normalized (case/space); ambiguity -> no auto.
|
||||
TC-05 auto_approve_applies / auto_deploy_applies: CSV scope + self-hosting.
|
||||
TC-06 auto_label_enabled=False -> applies() False -> has_label never reached
|
||||
(no network call on the gate).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_labels.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import labels # noqa: E402
|
||||
from src import plane_sync # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enabled_self_hosting(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "auto_approve_label", "autoApprove", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_repos", "", raising=False)
|
||||
# Keep _resolve_project_id offline-deterministic.
|
||||
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1")
|
||||
yield
|
||||
|
||||
|
||||
# --- TC-01 / TC-02 ---------------------------------------------------------
|
||||
def test_tc01_has_label_present(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is True
|
||||
|
||||
|
||||
def test_tc02_has_label_absent(monkeypatch):
|
||||
# Issue carries a different label uuid than the project's autoApprove uuid.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-OTHER"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc02_has_label_empty_issue_labels(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: [])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
# --- TC-03: fail-safe / never-raise ----------------------------------------
|
||||
def test_tc03_fetch_none_failsafe(monkeypatch):
|
||||
# fetch returns None (could-not-read) -> False, no auto.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: None)
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc03_fetch_raises_failsafe(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("plane down")
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", boom)
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
# Never raises; degrades to no auto.
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc03_project_map_empty_failsafe(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
# --- TC-04: normalization + ambiguity --------------------------------------
|
||||
def test_tc04_normalized_match(monkeypatch):
|
||||
# Issue label uuid-A; project maps a differently-cased/spaced name to it.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
# Sought name has different case + surrounding spaces.
|
||||
assert labels.has_label("ORCH-1", " AUTOapprove ") is True
|
||||
|
||||
|
||||
def test_tc04_ambiguous_name_no_auto(monkeypatch):
|
||||
# Two distinct project labels collapse to the same normalized name -> ambiguous
|
||||
# sentinel from get_project_labels -> has_label False.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(
|
||||
plane_sync, "get_project_labels",
|
||||
lambda pid: {"autoapprove": "__AMBIGUOUS__"},
|
||||
)
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc04_empty_label_name(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "") is False
|
||||
|
||||
|
||||
# --- TC-05: scope (CSV + self-hosting) -------------------------------------
|
||||
def test_tc05_empty_csv_self_hosting_only(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_repos", "", raising=False)
|
||||
assert labels.auto_approve_applies("orchestrator") is True
|
||||
assert labels.auto_deploy_applies("orchestrator") is True
|
||||
# Non-self repo with empty CSV -> not in scope.
|
||||
assert labels.auto_approve_applies("enduro-trails") is False
|
||||
assert labels.auto_deploy_applies("enduro-trails") is False
|
||||
|
||||
|
||||
def test_tc05_csv_membership(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_repos", "enduro-trails, foo", raising=False)
|
||||
assert labels.auto_approve_applies("enduro-trails") is True
|
||||
assert labels.auto_deploy_applies("foo") is True
|
||||
# orchestrator is NOT in the explicit CSV -> out of scope.
|
||||
assert labels.auto_approve_applies("orchestrator") is False
|
||||
|
||||
|
||||
# --- TC-06: kill-switch -> applies False, no network -----------------------
|
||||
def test_tc06_killswitch_applies_false(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_enabled", False, raising=False)
|
||||
assert labels.auto_approve_applies("orchestrator") is False
|
||||
assert labels.auto_deploy_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc06_killswitch_gate_makes_no_network(monkeypatch):
|
||||
"""The gate idiom `applies(repo) and has_label(...)` short-circuits before any
|
||||
network call when the kill-switch is off (AC-8)."""
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_enabled", False, raising=False)
|
||||
called = {"fetch": 0}
|
||||
|
||||
def spy(*a, **k):
|
||||
called["fetch"] += 1
|
||||
return ["uuid-A"]
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", spy)
|
||||
|
||||
repo = "orchestrator"
|
||||
fired = labels.auto_approve_applies(repo) and labels.has_label("ORCH-1", "autoApprove")
|
||||
assert fired is False
|
||||
assert called["fetch"] == 0 # has_label never reached -> zero network
|
||||
|
||||
|
||||
def test_snapshot_never_raises():
|
||||
snap = labels.snapshot()
|
||||
assert set(snap) >= {"enabled", "approve_label", "deploy_label", "repos"}
|
||||
164
tests/test_plane_sync_labels.py
Normal file
164
tests/test_plane_sync_labels.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""ORCH-089 — plane_sync: label reading + Approved setter (offline, httpx mocked).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-07 fetch_issue_labels parses the issue's `labels` field; get_project_labels
|
||||
resolves {normalized_name -> uuid}.
|
||||
TC-08 the project label-map is cached with a TTL (a repeat inside the TTL window
|
||||
makes no second GET).
|
||||
TC-09 set_issue_approved PATCHes the issue to the Approved UUID; never-raise.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_ps_labels.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
from src import plane_sync as ps # noqa: E402
|
||||
|
||||
|
||||
def _resp(json_body):
|
||||
m = MagicMock()
|
||||
m.json.return_value = json_body
|
||||
m.raise_for_status.return_value = None
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_cache(monkeypatch):
|
||||
ps.reload_project_labels()
|
||||
monkeypatch.setattr(ps, "_resolve_project_id", lambda w=None, p=None: "proj-1")
|
||||
monkeypatch.setattr(ps.settings, "auto_label_states_ttl_s", 300, raising=False)
|
||||
yield
|
||||
ps.reload_project_labels()
|
||||
|
||||
|
||||
# --- TC-07: fetch_issue_labels + get_project_labels ------------------------
|
||||
def test_tc07_fetch_issue_labels(monkeypatch):
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
monkeypatch.setattr(
|
||||
ps.httpx, "get",
|
||||
lambda *a, **k: _resp({"labels": ["uuid-A", "uuid-B"]}),
|
||||
)
|
||||
assert ps.fetch_issue_labels("ORCH-1") == ["uuid-A", "uuid-B"]
|
||||
|
||||
|
||||
def test_tc07_fetch_issue_labels_not_found(monkeypatch):
|
||||
# Issue not resolvable -> None (distinct from [] = "no labels").
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: None)
|
||||
assert ps.fetch_issue_labels("ORCH-404") is None
|
||||
|
||||
|
||||
def test_tc07_fetch_issue_labels_api_error(monkeypatch):
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
monkeypatch.setattr(ps.httpx, "get", MagicMock(side_effect=Exception("boom")))
|
||||
assert ps.fetch_issue_labels("ORCH-1") is None # never-raise
|
||||
|
||||
|
||||
def test_tc07_get_project_labels_normalized(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ps.httpx, "get",
|
||||
lambda *a, **k: _resp({"results": [
|
||||
{"id": "uuid-A", "name": "autoApprove"},
|
||||
{"id": "uuid-B", "name": "Auto Deploy"},
|
||||
]}),
|
||||
)
|
||||
m = ps.get_project_labels("proj-1")
|
||||
assert m["autoapprove"] == "uuid-A"
|
||||
assert m["auto deploy"] == "uuid-B"
|
||||
|
||||
|
||||
def test_tc07_get_project_labels_ambiguous(monkeypatch):
|
||||
# Two distinct labels collapse to the same normalized name -> sentinel.
|
||||
monkeypatch.setattr(
|
||||
ps.httpx, "get",
|
||||
lambda *a, **k: _resp([
|
||||
{"id": "uuid-A", "name": "autoApprove"},
|
||||
{"id": "uuid-B", "name": "AUTOAPPROVE"},
|
||||
]),
|
||||
)
|
||||
m = ps.get_project_labels("proj-1")
|
||||
assert m["autoapprove"] == "__AMBIGUOUS__"
|
||||
|
||||
|
||||
def test_tc07_get_project_labels_api_error_empty(monkeypatch):
|
||||
monkeypatch.setattr(ps.httpx, "get", MagicMock(side_effect=Exception("down")))
|
||||
assert ps.get_project_labels("proj-1") == {} # never-raise, no cache -> {}
|
||||
|
||||
|
||||
# --- TC-08: TTL cache ------------------------------------------------------
|
||||
def test_tc08_label_map_cached_within_ttl(monkeypatch):
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
|
||||
mock_get = MagicMock(side_effect=lambda *a, **k: _resp(
|
||||
{"results": [{"id": "uuid-A", "name": "autoApprove"}]}
|
||||
))
|
||||
monkeypatch.setattr(ps.httpx, "get", mock_get)
|
||||
|
||||
ps.get_project_labels("proj-1")
|
||||
ps.get_project_labels("proj-1") # within TTL -> served from cache
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
# Past the TTL -> refetch.
|
||||
clock["t"] += 301
|
||||
ps.get_project_labels("proj-1")
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
def test_tc08_ttl_zero_lifetime_cache(monkeypatch):
|
||||
monkeypatch.setattr(ps.settings, "auto_label_states_ttl_s", 0, raising=False)
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
|
||||
mock_get = MagicMock(side_effect=lambda *a, **k: _resp(
|
||||
[{"id": "uuid-A", "name": "autoApprove"}]
|
||||
))
|
||||
monkeypatch.setattr(ps.httpx, "get", mock_get)
|
||||
ps.get_project_labels("proj-1")
|
||||
clock["t"] += 100000
|
||||
ps.get_project_labels("proj-1")
|
||||
assert mock_get.call_count == 1 # lifetime cache, never expires
|
||||
|
||||
|
||||
def test_tc08_stale_served_on_refresh_failure(monkeypatch):
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
|
||||
responses = iter([
|
||||
_resp({"results": [{"id": "uuid-A", "name": "autoApprove"}]}),
|
||||
Exception("transient"),
|
||||
])
|
||||
|
||||
def flaky(*a, **k):
|
||||
r = next(responses)
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
monkeypatch.setattr(ps.httpx, "get", flaky)
|
||||
ps.get_project_labels("proj-1")
|
||||
clock["t"] += 301 # force a refresh that fails -> stale map served
|
||||
m = ps.get_project_labels("proj-1")
|
||||
assert m["autoapprove"] == "uuid-A"
|
||||
|
||||
|
||||
# --- TC-09: set_issue_approved ---------------------------------------------
|
||||
def test_tc09_set_issue_approved_patches_approved_uuid(monkeypatch):
|
||||
monkeypatch.setattr(ps, "get_project_states", lambda pid: {"approved": "approved-uuid"})
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
patch_spy = MagicMock(return_value=_resp({}))
|
||||
monkeypatch.setattr(ps.httpx, "patch", patch_spy)
|
||||
|
||||
ps.set_issue_approved("ORCH-1")
|
||||
|
||||
patch_spy.assert_called_once()
|
||||
assert patch_spy.call_args.kwargs["json"] == {"state": "approved-uuid"}
|
||||
|
||||
|
||||
def test_tc09_set_issue_approved_never_raises(monkeypatch):
|
||||
monkeypatch.setattr(ps, "get_project_states", lambda pid: {"approved": "approved-uuid"})
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
monkeypatch.setattr(ps.httpx, "patch", MagicMock(side_effect=Exception("boom")))
|
||||
# Must not raise.
|
||||
ps.set_issue_approved("ORCH-1")
|
||||
@@ -79,6 +79,11 @@ def setup(monkeypatch):
|
||||
monkeypatch.setattr(P.settings, "db_path", _test_db)
|
||||
import src.db as _db
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
# ORCH-088: these are pre-ORCH-088 repo-routing tests that assert the branch is
|
||||
# cut DURING start_pipeline. With the serial gate ON (default) the branch cut is
|
||||
# deferred to the analyst-job claim, so pin them to the kill-switch-off (legacy)
|
||||
# path — branch timing is out of scope here (covered by test_serial_gate_branch).
|
||||
monkeypatch.setattr(_db.settings, "serial_gate_enabled", False, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
|
||||
61
tests/test_queue_endpoint.py
Normal file
61
tests/test_queue_endpoint.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""ORCH-088 — GET /queue additive serial_gate block (AC-10 / TC-20).
|
||||
|
||||
The /queue payload must gain an additive ``serial_gate`` block WITHOUT changing
|
||||
any pre-existing key (counts/max_concurrency/reconcile/reaper/post_deploy/
|
||||
task_deps/recent ...).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_queue_endpoint.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "q.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_queue_has_serial_gate_block_and_keeps_existing_keys():
|
||||
import asyncio
|
||||
from src import main
|
||||
|
||||
payload = asyncio.run(main.queue())
|
||||
|
||||
# Pre-existing keys are all still present (no contract break).
|
||||
for key in (
|
||||
"counts", "max_concurrency", "poll_interval", "resilience", "reconcile",
|
||||
"reaper", "post_deploy", "merge_verify", "task_deps", "recent",
|
||||
):
|
||||
assert key in payload, f"existing /queue key '{key}' must be preserved"
|
||||
|
||||
# New additive block.
|
||||
assert "serial_gate" in payload
|
||||
sg = payload["serial_gate"]
|
||||
assert sg["enabled"] is True
|
||||
assert "repos" in sg and "freeze_enabled" in sg
|
||||
assert isinstance(sg["per_repo"], dict)
|
||||
|
||||
|
||||
def test_queue_serial_gate_reflects_freeze():
|
||||
import asyncio
|
||||
from src import main
|
||||
from src import serial_gate
|
||||
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-900")
|
||||
payload = asyncio.run(main.queue())
|
||||
per = payload["serial_gate"]["per_repo"]
|
||||
assert "orchestrator" in per
|
||||
assert per["orchestrator"]["frozen"] is True
|
||||
assert per["orchestrator"]["frozen_reason"] == "DEGRADED"
|
||||
188
tests/test_serial_gate.py
Normal file
188
tests/test_serial_gate.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""ORCH-088 — per-repo serial gate, unit tests (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-01 claim_next_job does NOT claim an analyst-job of a NEW task B while the
|
||||
repo has an unfinished task A (gate closed).
|
||||
TC-02 serial_gate_applies: enabled + empty CSV -> True for any repo; CSV
|
||||
membership -> True; repo outside CSV -> False; disabled -> False.
|
||||
TC-03 jobs of an ALREADY-active task (architect/developer/.../deployer) are
|
||||
never gated — the single active task advances freely.
|
||||
TC-08 per-repo: an active orchestrator task does NOT gate an enduro analyst-job.
|
||||
TC-15 kill-switch off -> claim is 1:1 as before ORCH-088.
|
||||
TC-16 repo outside a non-empty CSV -> gate inert for that repo.
|
||||
TC-17 DB/build error in the gate -> fail-OPEN: claim does not crash, still claims.
|
||||
TC-19 snapshot() shape + never-raise.
|
||||
TC-21 STAGE_TRANSITIONS / QG_CHECKS registries unchanged (no new QG check).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import serial_gate # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "sg.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
# Feature ON by default; freeze layer ON; empty CSV (all repos).
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
# Keep the unrelated dep-gate inert so claim semantics isolate the serial gate.
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _set_stage(task_id, stage):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-01
|
||||
def test_gate_closed_when_repo_has_active_task():
|
||||
_make_task("ORCH-201", stage="development") # active predecessor
|
||||
b = _make_task("ORCH-202", stage="analysis") # new task
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
# A is unfinished -> the analyst-job of B is NOT claimable.
|
||||
assert claim_next_job() is None, "analyst-job must be gated by active task A"
|
||||
# /queue shows B waiting + an active task for the repo.
|
||||
snap = serial_gate.snapshot()
|
||||
per = snap["per_repo"]["orchestrator"]
|
||||
assert per["active_task"]["work_item_id"] == "ORCH-201"
|
||||
assert any(w["job_id"] == job_b for w in per["waiting"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-02
|
||||
def test_serial_gate_applies_scopes(monkeypatch):
|
||||
# enabled + empty CSV -> all repos.
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is True
|
||||
assert serial_gate.serial_gate_applies("enduro-trails") is True
|
||||
# CSV membership.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "orchestrator", raising=False)
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is True
|
||||
assert serial_gate.serial_gate_applies("enduro-trails") is False
|
||||
# kill-switch off -> never applies.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False)
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-03
|
||||
def test_non_analyst_job_of_active_task_passes():
|
||||
a = _make_task("ORCH-210", stage="development")
|
||||
# an unrelated unfinished task in the same repo (would close the gate for analyst)
|
||||
_make_task("ORCH-211", stage="analysis")
|
||||
for role in ("architect", "developer", "reviewer", "tester", "deployer"):
|
||||
jid = enqueue_job(role, "orchestrator", role, task_id=a)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == jid, (
|
||||
f"{role}-job of an active task must never be gated"
|
||||
)
|
||||
# finish it so the next role's job is the only queued one.
|
||||
db.mark_job(jid, "done")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-08
|
||||
def test_per_repo_isolation():
|
||||
# orchestrator busy; enduro gets a brand-new analyst-job.
|
||||
_make_task("ORCH-220", stage="development", repo="orchestrator")
|
||||
b = _make_task("ET-220", stage="analysis", repo="enduro-trails")
|
||||
job_b = enqueue_job("analyst", "enduro-trails", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"orchestrator's active task must not gate enduro's analyst-job"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-15
|
||||
def test_kill_switch_off_is_inert(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False)
|
||||
_make_task("ORCH-230", stage="development") # active task
|
||||
b = _make_task("ORCH-231", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"with the kill-switch off the gate must be inert (claims as before)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-16
|
||||
def test_repo_outside_csv_not_gated(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "enduro-trails", raising=False)
|
||||
_make_task("ORCH-240", stage="development") # active orchestrator task
|
||||
b = _make_task("ORCH-241", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"orchestrator is outside the CSV scope -> gate must not apply"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-17
|
||||
def test_build_clause_error_fails_open(monkeypatch):
|
||||
"""A build error in the gate clause must fail-OPEN (claim still proceeds)."""
|
||||
_make_task("ORCH-250", stage="development") # would close the gate
|
||||
b = _make_task("ORCH-251", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("clause build down")
|
||||
|
||||
monkeypatch.setattr(serial_gate, "build_claim_clause", _boom, raising=True)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"a gate build error must fail-OPEN, not wedge the queue (AC-8)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-19
|
||||
def test_snapshot_shape_and_never_raises(monkeypatch):
|
||||
snap = serial_gate.snapshot()
|
||||
assert snap["enabled"] is True
|
||||
assert "repos" in snap and "freeze_enabled" in snap
|
||||
assert isinstance(snap["per_repo"], dict)
|
||||
# never-raise: a DB failure -> minimal dict with flags, empty per_repo.
|
||||
monkeypatch.setattr(
|
||||
serial_gate, "_known_repos",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
raising=True,
|
||||
)
|
||||
snap2 = serial_gate.snapshot()
|
||||
assert snap2["per_repo"] == {}
|
||||
assert snap2["enabled"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-21
|
||||
def test_registries_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
from src.qg.checks import QG_CHECKS
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done",
|
||||
}
|
||||
# No serial-gate QG check was introduced (the gate is a scheduler condition).
|
||||
assert not any("serial" in k for k in QG_CHECKS), "no new QG check expected"
|
||||
153
tests/test_serial_gate_branch.py
Normal file
153
tests/test_serial_gate_branch.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""ORCH-088 — deferred branch cut / anti-stale-base (FR-1/AC-6).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-13 while the serial gate applies, start_pipeline does NOT create the Gitea
|
||||
branch / initial docs (the cut is deferred to the analyst-job claim);
|
||||
with the kill-switch off it creates them immediately (1:1 as before).
|
||||
TC-14 a branch cut at claim time (ensure_worktree on a not-yet-existing branch)
|
||||
is based on a FRESH origin/main that already contains the predecessor:
|
||||
git merge-base --is-ancestor <sha A> <base B> is true.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_branch.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "branch.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_source", "db", raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-13
|
||||
async def _drive_start_pipeline(monkeypatch, gate_applies: bool):
|
||||
from src.webhooks import plane
|
||||
from src import plane_sync
|
||||
from src.projects import ProjectConfig
|
||||
|
||||
proj = ProjectConfig(
|
||||
plane_project_id="proj-uuid",
|
||||
repo="orchestrator",
|
||||
work_item_prefix="ORCH",
|
||||
name="orch",
|
||||
)
|
||||
monkeypatch.setattr(plane, "get_project_by_plane_id", lambda pid: proj)
|
||||
monkeypatch.setattr(plane, "_qg0_errors", lambda name, desc: [])
|
||||
monkeypatch.setattr(plane, "ensure_unique_work_item_id", lambda wid, repo: wid)
|
||||
monkeypatch.setattr(
|
||||
plane, "create_task_atomic",
|
||||
lambda *a, **k: ({"id": 1, "work_item_id": "ORCH-500"}, True),
|
||||
)
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_sequence_id", lambda *a, **k: 500)
|
||||
monkeypatch.setattr(plane_sync, "set_issue_analysis", lambda *a, **k: None)
|
||||
monkeypatch.setattr(plane_sync, "add_comment", lambda *a, **k: None)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", gate_applies, raising=False)
|
||||
|
||||
enq = []
|
||||
monkeypatch.setattr(plane, "enqueue_job", lambda *a, **k: (enq.append((a, k)) or 99))
|
||||
|
||||
branch_calls, docs_calls = [], []
|
||||
|
||||
async def _branch_spy(repo, branch):
|
||||
branch_calls.append((repo, branch))
|
||||
|
||||
async def _docs_spy(repo, branch, wi, name):
|
||||
docs_calls.append((repo, branch, wi, name))
|
||||
|
||||
monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy)
|
||||
monkeypatch.setattr(plane, "_create_initial_docs", _docs_spy)
|
||||
|
||||
data = {
|
||||
"id": "issue-uuid-1",
|
||||
"name": "Add serial gate",
|
||||
"description_stripped": "A sufficiently long description for QG-0 to pass.",
|
||||
"project": "proj-uuid",
|
||||
}
|
||||
await plane.start_pipeline(data, project_id="proj-uuid")
|
||||
return branch_calls, docs_calls, enq
|
||||
|
||||
|
||||
def test_branch_cut_deferred_when_gate_applies(monkeypatch):
|
||||
import asyncio
|
||||
branch_calls, docs_calls, enq = asyncio.run(
|
||||
_drive_start_pipeline(monkeypatch, gate_applies=True)
|
||||
)
|
||||
assert branch_calls == [], "branch must NOT be cut in start_pipeline while gated"
|
||||
assert docs_calls == [], "initial docs must NOT be created while gated"
|
||||
# The analyst-job is still enqueued (it waits in the queue without a branch).
|
||||
assert any(a[0] == "analyst" for a, k in enq), "analyst-job must still be enqueued"
|
||||
|
||||
|
||||
def test_branch_cut_immediate_when_kill_switch_off(monkeypatch):
|
||||
import asyncio
|
||||
branch_calls, docs_calls, enq = asyncio.run(
|
||||
_drive_start_pipeline(monkeypatch, gate_applies=False)
|
||||
)
|
||||
assert branch_calls, "with the gate off the branch is cut in start_pipeline (1:1)"
|
||||
assert docs_calls, "with the gate off initial docs are created in start_pipeline"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-14
|
||||
def _git(*args, cwd):
|
||||
env = {
|
||||
**os.environ,
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
}
|
||||
return subprocess.run(["git", *args], cwd=cwd, env=env,
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
|
||||
def test_deferred_branch_base_contains_predecessor(tmp_path, monkeypatch):
|
||||
"""A branch cut at claim time is based on a fresh origin/main with A's code."""
|
||||
from src import git_worktree
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
origin.mkdir()
|
||||
_git("init", "--bare", "-b", "main", str(origin), cwd=tmp_path)
|
||||
|
||||
repos_dir = tmp_path / "repos"
|
||||
wt_dir = tmp_path / "wt"
|
||||
repos_dir.mkdir()
|
||||
wt_dir.mkdir()
|
||||
repo = "orchestrator"
|
||||
clone = repos_dir / repo
|
||||
_git("clone", str(origin), str(clone), cwd=tmp_path)
|
||||
|
||||
# Predecessor A: commit on main + push to origin (== "A merged at its done").
|
||||
(clone / "a.txt").write_text("A's code\n")
|
||||
_git("add", "a.txt", cwd=clone)
|
||||
_git("commit", "-m", "task A", cwd=clone)
|
||||
_git("push", "origin", "main", cwd=clone)
|
||||
sha_a = _git("rev-parse", "HEAD", cwd=clone).stdout.strip()
|
||||
|
||||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir), raising=False)
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir), raising=False)
|
||||
|
||||
# Branch B does not exist yet -> ensure_worktree cuts it from fresh origin/main.
|
||||
wt = git_worktree.ensure_worktree(repo, "feature/ORCH-B")
|
||||
head_b = _git("rev-parse", "HEAD", cwd=wt).stdout.strip()
|
||||
|
||||
# AC-6: A's commit is an ancestor of B's base.
|
||||
r = subprocess.run(
|
||||
["git", "-C", wt, "merge-base", "--is-ancestor", sha_a, head_b],
|
||||
capture_output=True,
|
||||
)
|
||||
assert r.returncode == 0, "branch B base must contain predecessor A's commit (AC-6)"
|
||||
113
tests/test_serial_gate_e2e.py
Normal file
113
tests/test_serial_gate_e2e.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""ORCH-088 — serial gate end-to-end queue behaviour (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-04 after A.stage='done' the waiting analyst-job of B is claimed (gate opens
|
||||
automatically — no manual action).
|
||||
TC-05 a queue of 3 tasks of one repo is processed strictly one-at-a-time, FIFO
|
||||
by jobs.id: while A is unfinished neither B nor C starts.
|
||||
TC-06 restart-safe: the active task is derived from the DB (tasks.repo +
|
||||
stage!='done'), not in-memory — re-reading state keeps the gate closed.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_e2e.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "e2e.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _set_stage(task_id, stage):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-04
|
||||
def test_next_starts_automatically_when_predecessor_done():
|
||||
a = _make_task("ORCH-301", stage="development")
|
||||
b = _make_task("ORCH-302", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
assert claim_next_job() is None, "B gated while A unfinished"
|
||||
|
||||
# A reaches done -> the gate opens on the NEXT claim tick, no manual action.
|
||||
_set_stage(a, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-05
|
||||
def test_three_tasks_processed_one_at_a_time_fifo():
|
||||
a = _make_task("ORCH-310", stage="analysis")
|
||||
b = _make_task("ORCH-311", stage="analysis")
|
||||
c = _make_task("ORCH-312", stage="analysis")
|
||||
job_a = enqueue_job("analyst", "orchestrator", "A", task_id=a)
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
job_c = enqueue_job("analyst", "orchestrator", "C", task_id=c)
|
||||
|
||||
# Only the FIFO-first task (A, lowest id) is claimable.
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_a
|
||||
assert claim_next_job() is None, "B and C must wait while A is unfinished"
|
||||
|
||||
# A runs through to done; now B (next) is claimable, C still waits.
|
||||
db.mark_job(job_a, "done")
|
||||
_set_stage(a, "done")
|
||||
claimed_b = claim_next_job()
|
||||
assert claimed_b is not None and claimed_b["id"] == job_b
|
||||
assert claim_next_job() is None, "C must wait while B is unfinished"
|
||||
|
||||
# B done -> C claimable last (strict FIFO order preserved).
|
||||
db.mark_job(job_b, "done")
|
||||
_set_stage(b, "done")
|
||||
claimed_c = claim_next_job()
|
||||
assert claimed_c is not None and claimed_c["id"] == job_c
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-06
|
||||
def test_restart_safe_active_task_from_db():
|
||||
a = _make_task("ORCH-320", stage="development")
|
||||
b = _make_task("ORCH-321", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert claim_next_job() is None
|
||||
|
||||
# Simulate a restart: there is NO in-memory state — the gate recomputes purely
|
||||
# from the DB. Re-running init_db (idempotent) + a fresh claim must still gate B.
|
||||
init_db()
|
||||
assert claim_next_job() is None, "after restart the gate is still closed (DB-derived)"
|
||||
|
||||
_set_stage(a, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
160
tests/test_serial_gate_freeze.py
Normal file
160
tests/test_serial_gate_freeze.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""ORCH-088 — rollback-freeze layer (FR-5) tests (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-07 freeze survives a restart (durable in DB) — next task stays gated.
|
||||
TC-09 freeze of orchestrator does NOT affect enduro-trails (per-repo).
|
||||
TC-10 post-deploy DEGRADED -> durable freeze row + Telegram alert attempted.
|
||||
TC-11 an active freeze gates the next analyst-job even with NO unfinished task
|
||||
(the degraded task is already done — BR-7).
|
||||
TC-12 manual clear_repo_freeze -> next task is claimable again.
|
||||
TC-18 is_repo_frozen fails CLOSED on a read error (frozen=True on doubt).
|
||||
TC-22 repo_freeze migration is idempotent (re-init does not dup / crash).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_freeze.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import serial_gate # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "freeze.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-07
|
||||
def test_freeze_survives_restart():
|
||||
b = _make_task("ORCH-401", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert serial_gate.set_repo_freeze("orchestrator", "post-deploy DEGRADED", "ORCH-400") is True
|
||||
|
||||
assert claim_next_job() is None, "frozen repo gates the analyst-job"
|
||||
# Simulate restart: no in-memory state, re-init (idempotent) -> still frozen.
|
||||
init_db()
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
assert claim_next_job() is None, "freeze is durable across restart"
|
||||
assert job_b # referenced
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-09
|
||||
def test_freeze_is_per_repo():
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-410")
|
||||
b = _make_task("ET-410", stage="analysis", repo="enduro-trails")
|
||||
job_b = enqueue_job("analyst", "enduro-trails", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"an orchestrator freeze must not gate enduro-trails"
|
||||
)
|
||||
assert serial_gate.is_repo_frozen("enduro-trails") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-10
|
||||
def test_post_deploy_degraded_sets_freeze_and_alerts(tmp_path, monkeypatch):
|
||||
from src import stage_engine, post_deploy
|
||||
|
||||
# Sandbox the post-deploy sentinel state dir so a prior DONE marker can't
|
||||
# short-circuit the tick (state lives under settings.repos_dir).
|
||||
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
|
||||
a = _make_task("ORCH-420", stage="done", repo="orchestrator")
|
||||
job = {"task_id": a, "repo": "orchestrator"}
|
||||
|
||||
# Avoid network / git / worktree; force a DEGRADED verdict.
|
||||
monkeypatch.setattr(post_deploy, "probe_signals",
|
||||
lambda *a, **k: post_deploy.ProbeResult(False, 2, 2, "down"))
|
||||
monkeypatch.setattr(post_deploy, "classify", lambda *a, **k: post_deploy.DEGRADED)
|
||||
monkeypatch.setattr(post_deploy, "write_post_deploy_log", lambda *a, **k: True)
|
||||
monkeypatch.setattr(stage_engine, "set_issue_blocked", lambda *a, **k: None)
|
||||
|
||||
alerts = []
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy",
|
||||
lambda wi, msg: alerts.append(msg))
|
||||
|
||||
stage_engine.run_post_deploy_monitor(job)
|
||||
|
||||
# Durable freeze row written + a freeze alert attempted.
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
assert any("ЗАМОРОЖЕН" in m for m in alerts), f"freeze alert missing: {alerts}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-11
|
||||
def test_freeze_gates_even_without_unfinished_task():
|
||||
_make_task("ORCH-430", stage="done") # degraded task already done
|
||||
b = _make_task("ORCH-431", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
# Without freeze B would be claimable (A done, no earlier unfinished). Freeze it.
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-430")
|
||||
assert claim_next_job() is None, "active freeze gates the next analyst-job (BR-7)"
|
||||
assert job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-12
|
||||
def test_manual_unfreeze_lets_next_start():
|
||||
_make_task("ORCH-440", stage="done")
|
||||
b = _make_task("ORCH-441", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-440")
|
||||
assert claim_next_job() is None
|
||||
|
||||
cleared = serial_gate.clear_repo_freeze("orchestrator")
|
||||
assert cleared >= 1
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is False
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
# Idempotent: clearing again clears nothing.
|
||||
assert serial_gate.clear_repo_freeze("orchestrator") == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-18
|
||||
def test_is_repo_frozen_fails_closed(monkeypatch):
|
||||
def _boom(repo):
|
||||
raise RuntimeError("freeze read down")
|
||||
|
||||
monkeypatch.setattr(serial_gate, "_active_freeze_row", _boom, raising=True)
|
||||
# Freeze layer enabled + cannot confirm absence -> fail CLOSED (True).
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
# Freeze layer OFF -> never frozen, even on a read error.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", False, raising=False)
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-22
|
||||
def test_repo_freeze_migration_idempotent():
|
||||
# Re-running init_db must not crash or duplicate the table/index.
|
||||
init_db()
|
||||
init_db()
|
||||
conn = get_db()
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(repo_freeze)").fetchall()]
|
||||
conn.close()
|
||||
assert {"repo", "frozen_at", "reason", "work_item_id", "cleared_at"}.issubset(set(cols))
|
||||
# A freeze still functions after repeated migration.
|
||||
assert serial_gate.set_repo_freeze("orchestrator", "x", "ORCH-450") is True
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
@@ -39,6 +39,11 @@ def setup(monkeypatch):
|
||||
monkeypatch.setattr(P.settings, "db_path", _test_db)
|
||||
import src.db as _db
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
# ORCH-088: this suite asserts the branch is cut DURING start_pipeline. With the
|
||||
# serial gate ON (default) the cut is deferred to the analyst-job claim, so pin
|
||||
# to the kill-switch-off (legacy) path — branch timing is out of scope here
|
||||
# (covered by test_serial_gate_branch).
|
||||
monkeypatch.setattr(_db.settings, "serial_gate_enabled", False, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
|
||||
Reference in New Issue
Block a user