Compare commits
16 Commits
docs/ORCH-
...
eeb442810e
| Author | SHA1 | Date | |
|---|---|---|---|
| eeb442810e | |||
| c0d34377d5 | |||
| a63dbb987f | |||
| 92d908a861 | |||
| 218c9b96c8 | |||
| cf5790b1a6 | |||
| 4c83ea40a3 | |||
| 50434fc2b1 | |||
|
|
6eb9992585 | ||
| e9b23d3c04 | |||
| e3c3292ec7 | |||
| 1ada41f272 | |||
| 62b4d1f7d1 | |||
| c5007e6c90 | |||
| 10510ac48c | |||
| 8ccd17e199 |
15
.env.example
15
.env.example
@@ -37,12 +37,15 @@ ORCH_AGENT_MODEL_DEVELOPER=
|
||||
ORCH_AGENT_MODEL_REVIEWER=
|
||||
ORCH_AGENT_MODEL_TESTER=
|
||||
ORCH_AGENT_MODEL_DEPLOYER=
|
||||
# Effort split: thinking agents (analyst/architect/developer/reviewer) -> high;
|
||||
# mechanical agents (tester/deployer) -> medium.
|
||||
# Effort split (ORCH-081/ORCH-52h): thinking agents (analyst/architect/reviewer)
|
||||
# -> high; developer -> xhigh (coding/agentic role, Opus 4.8 canon); mechanical
|
||||
# agents (tester/deployer) -> medium. NB: an empty ORCH_AGENT_EFFORT_*= no longer
|
||||
# zeroes the effort — the launcher falls back to a per-role floor (= the config.py
|
||||
# class-default) so each role still runs at its canonical level (ORCH-081).
|
||||
ORCH_AGENT_EFFORT_DEFAULT=high
|
||||
ORCH_AGENT_EFFORT_ANALYST=high
|
||||
ORCH_AGENT_EFFORT_ARCHITECT=high
|
||||
ORCH_AGENT_EFFORT_DEVELOPER=high
|
||||
ORCH_AGENT_EFFORT_DEVELOPER=xhigh
|
||||
ORCH_AGENT_EFFORT_REVIEWER=high
|
||||
ORCH_AGENT_EFFORT_TESTER=medium
|
||||
ORCH_AGENT_EFFORT_DEPLOYER=medium
|
||||
@@ -120,11 +123,17 @@ ORCH_TASK_DEPS_SOURCE=db
|
||||
# REGRESSION_GUARD_ENABLED -> kill-switch for the ORCH-073 main-integrity regression
|
||||
# guard (false -> SHA-in-main alone gates done); reuses the
|
||||
# merge-verify scope, so non-self repos are a no-op.
|
||||
# MERGE_VERIFY_AUTOCREATE_PR_ENABLED -> ORCH-082: guarantee an open code-PR
|
||||
# (head==branch, base==main) via merge_gate.ensure_open_pr
|
||||
# BEFORE the deterministic merge_pr (fixes the false HOLD
|
||||
# "no open PR"). false -> exactly pre-ORCH-082 behaviour.
|
||||
# Reuses the merge-verify scope; non-self repos -> no-op.
|
||||
ORCH_MERGE_VERIFY_ENABLED=true
|
||||
ORCH_MERGE_VERIFY_REPOS=
|
||||
ORCH_MERGE_PR_TIMEOUT_S=60
|
||||
ORCH_MERGE_VERIFY_TIMEOUT_S=60
|
||||
ORCH_REGRESSION_GUARD_ENABLED=true
|
||||
ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true
|
||||
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
|
||||
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
|
||||
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,13 +42,13 @@ created → analysis → architecture → development → review → testing →
|
||||
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
|
||||
|
||||
### Модель и эффорт по ролям (ORCH-41, валидация ORCH-74)
|
||||
Модель и `--effort` каждого агента берутся из config (`src/config.py`), резолвятся `launcher.resolve_agent_model` / `resolve_agent_effort` по приоритету **project-override (`projects_json` `agent_models`/`agent_efforts`) > `ORCH_AGENT_MODEL_<AGENT>`/`ORCH_AGENT_EFFORT_<AGENT>` > `*_default` > CLI-дефолт (без флага)**. frontmatter `model:` в `.openclaw/agents/*.md` **удалён** (ORCH-74 G1) — он был мёртвой/лживой декларацией (launcher его не читает); config — единственный источник правды о модели. Model-routing (G3) НЕ включён — все 6 агентов на `claude-opus-4-8`.
|
||||
Модель и `--effort` каждого агента берутся из config (`src/config.py`), резолвятся `launcher.resolve_agent_model` / `resolve_agent_effort` по приоритету **project-override (`projects_json` `agent_models`/`agent_efforts`) > `ORCH_AGENT_MODEL_<AGENT>`/`ORCH_AGENT_EFFORT_<AGENT>` > `*_default` > CLI-дефолт (без флага)**. **Эффорт (ORCH-081):** ниже `*_default` добавлен непустой **per-role floor** — class-default поля `agent_effort_<role>` из `config.py` (его пустой env перебить не может). Floor — строго последний уровень (ниже default) и срабатывает ТОЛЬКО когда все уровни пусты, поэтому пустые прод-`ORCH_AGENT_EFFORT_*=` (которые pydantic трактует как явное `''` и обнуляют дефолт) больше не приводят к запуску без `--effort`: каждая роль получает свой канонический пол (developer=`xhigh`, tester/deployer=`medium`, прочие=`high`). Непустой явный конфиг по-прежнему побеждает floor; опечатка вне `VALID_EFFORTS` дропается валидацией ДО floor (never-break, не маскируется). См. `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. frontmatter `model:` в `.openclaw/agents/*.md` **удалён** (ORCH-74 G1) — он был мёртвой/лживой декларацией (launcher его не читает); config — единственный источник правды о модели. Model-routing (G3) НЕ включён — все 6 агентов на `claude-opus-4-8`.
|
||||
|
||||
| Агент | Модель | Эффорт |
|
||||
|-------|--------|--------|
|
||||
| analyst | claude-opus-4-8 | high |
|
||||
| architect | claude-opus-4-8 | high |
|
||||
| developer | claude-opus-4-8 | high |
|
||||
| developer | claude-opus-4-8 | xhigh |
|
||||
| reviewer | claude-opus-4-8 | high |
|
||||
| tester | claude-opus-4-8 | medium |
|
||||
| deployer | claude-opus-4-8 | medium |
|
||||
@@ -206,6 +206,39 @@ merge-в-main вообще**. Detached host-деплой лишь retag'ал о
|
||||
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`,
|
||||
`docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.
|
||||
|
||||
#### Гарантированный код-PR перед merge-verify (ORCH-082 — фикс ложного HOLD «no open PR»)
|
||||
Под-гейт merge-verify (ORCH-071/073) детерминированно мержит **открытый** код-PR ветки в `main`
|
||||
(`merge_pr`, фильтр `head.ref==branch` И `base.ref=="main"`). Но конвейер **не гарантировал**, что
|
||||
к моменту merge у ветки этот PR есть: PR создаётся единственной `launcher._ensure_pr` **только** на
|
||||
developer-пути и **только** при свежем worktree-коммите. На деплое ORCH-074 (08.06, первая задача
|
||||
после ручных восстановлений `main`) у ветки не оказалось открытого код-PR → `merge_pr` вернул
|
||||
`("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но это
|
||||
лечило следствие. ORCH-082 закрывает **отсутствующий инвариант** «к merge-verify у ветки есть
|
||||
открытый код-PR» аддитивно, внутри того же под-гейта, не трогая машину стадий:
|
||||
- **Новый leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)`** (never-raise):
|
||||
`GET …/pulls?state=open` с фильтром `head.ref==branch` И `base.ref=="main"` (**идентичен**
|
||||
`merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base != main` НЕ код-PR) → `("existed", N)`; иначе
|
||||
`POST …/pulls` → `("created", N)`; гонка «PR exists»/409/422 → повторный GET → `existed` (без
|
||||
дублей); любая иная ошибка → `("failed", reason)`.
|
||||
- **Врезка в `_handle_merge_verify`** ПОСЛЕ резолва `validated_revision` и **ПЕРЕД** `merge_pr`:
|
||||
`created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD+alert
|
||||
через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось» — отличим от
|
||||
not-merged HOLD; `result.note="pr-create-failed-hold"`), задача остаётся на `deploy`, БЕЗ отката
|
||||
на development.
|
||||
- **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО
|
||||
`verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь
|
||||
**ложный** HOLD «no open PR», но не маскирует реально невлитый код (тот → HOLD как прежде).
|
||||
- **`launcher._ensure_pr`** рекомендуется делегировать в `ensure_open_pr` (единый код создания PR),
|
||||
сохранив прежний триггер «только developer-путь».
|
||||
- **Условность как ORCH-35/43/58/71:** kill-switch `merge_verify_autocreate_pr_enabled` (дефолт
|
||||
`true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); non-self —
|
||||
no-op. `False` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), **без
|
||||
миграции БД** (restart-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
|
||||
exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push.
|
||||
|
||||
Подробнее: [adr-0016](adr/adr-0016-ensure-open-pr-before-merge-verify.md) (amends 0013/0014);
|
||||
детально — `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`.
|
||||
|
||||
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
||||
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
||||
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
|
||||
|
||||
@@ -21,12 +21,14 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0013 | Merge-в-main + пост-деплой верификация как условие `done` | accepted | 2026-06-08 | ORCH-071 |
|
||||
| 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 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0015`).
|
||||
> свободный номер (текущий максимум — `0016`).
|
||||
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
|
||||
> adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082).
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-0016: ensure_open_pr — гарантированный код-PR перед merge-verify (ORCH-082)
|
||||
|
||||
## Статус
|
||||
Accepted — амендмент к [adr-0013](adr-0013-merge-verify-gate.md) и
|
||||
[adr-0014](adr-0014-merge-verify-sha-source-of-truth.md). Детально:
|
||||
`docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`.
|
||||
|
||||
## Контекст
|
||||
Merge-verify (ORCH-071/073) — под-гейт ребра `deploy → done`: детерминированно мержит код-PR в
|
||||
`main` (`merge_pr`) и подтверждает merge **только** по «SHA-в-main» (`verify_merged_to_main`,
|
||||
ORCH-073). На деплое ORCH-074 (08.06) `merge_pr` вернул `("False", "no open PR")`: у ветки **не
|
||||
было** открытого PR с `head==branch` И `base=="main"`. Защита ORCH-073 верно удержала задачу
|
||||
(HOLD, не ложный `done`), но это лечило **следствие**.
|
||||
|
||||
Первопричина (код-аудит): PR создаётся в конвейере **единственной** функцией
|
||||
`launcher._ensure_pr`, вызываемой **только** на developer-пути и **только** при свежем
|
||||
worktree-коммите. Любой сценарий без свежего developer-коммита (бойнс без правок, повторный
|
||||
прогон, **ручное восстановление ветки/`main`** — случай ORCH-074) оставляет ветку без код-PR.
|
||||
Инвариант «к merge-verify у ветки есть открытый код-PR» в конвейере **отсутствовал** → блокер
|
||||
автономного деплоя (ORCH-54).
|
||||
|
||||
## Решение
|
||||
Аддитивно обеспечить инвариант **внутри того же под-гейта**, ПЕРЕД `merge_pr`, не трогая машину
|
||||
стадий:
|
||||
|
||||
1. **Новый leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)`** (never-raise):
|
||||
`GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен
|
||||
`merge_pr`/ORCH-073 FR-3 — авто-docs-PR не считается код-PR) → `("existed", N)`; иначе
|
||||
`POST …/pulls` → `("created", N)`; гонка «PR exists» → повторный GET → `existed` (без дублей);
|
||||
любая ошибка → `("failed", reason)`.
|
||||
2. **Врезка в `_handle_merge_verify`** ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`:
|
||||
`created|existed` → штатно к `merge_pr`; `failed` → честный HOLD+alert через новый helper
|
||||
`_hold_pr_create_failed` (текст «PR создать не удалось» — отличим от not-merged HOLD), задача
|
||||
остаётся на `deploy`, БЕЗ отката на development.
|
||||
3. **Kill-switch `merge_verify_autocreate_pr_enabled`** (дефолт `True`); область —
|
||||
`merge_verify_applies` (self-hosting / `merge_verify_repos`). `False` → поведение ORCH-074 1:1.
|
||||
4. **`launcher._ensure_pr`** рекомендуется делегировать в `ensure_open_pr` (единый код создания
|
||||
PR), сохранив прежний триггер «только developer-путь».
|
||||
|
||||
## Последствия
|
||||
- **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО
|
||||
`verify_merged_to_main` (SHA-в-main) + `check_main_regression`. Создание PR устраняет лишь
|
||||
**ложный** HOLD «no open PR», но не маскирует реально невлитый код (тот → HOLD как прежде).
|
||||
- **Без миграций:** идемпотентность выводится из Gitea (наличие открытого PR), схема БД не меняется
|
||||
— restart-safe; повторный заход (reaper/reconciler/re-approve) → `existed`, дублей нет.
|
||||
- **Инварианты целы:** `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
|
||||
exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений; `main` не
|
||||
push/force-push; never-raise на всём пути.
|
||||
- **Наблюдаемость:** один однозначный исход в логах на проход — created / existed / failed; HOLD по
|
||||
failed текстуально отличим от HOLD not-merged.
|
||||
- **Минус:** код-PR может создаваться после прохождения гейтов — безопасно, т.к. гейты валидируют
|
||||
код ветки, а merge-verify идёт ПОСЛЕ всех гейтов; PR — лишь механизм слияния, ревью не обходится.
|
||||
7
docs/work-items/ORCH-081/00-business-request.md
Normal file
7
docs/work-items/ORCH-081/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-52h: эффорт агентов резолвится в пустую строку в проде (env перебивает config)
|
||||
|
||||
Work Item ID: ORCH-081
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
82
docs/work-items/ORCH-081/01-brd.md
Normal file
82
docs/work-items/ORCH-081/01-brd.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 01 — BRD: ORCH-081 (ORCH-52h)
|
||||
|
||||
**Work Item:** ORCH-081
|
||||
**Эпик:** ORCH-052 (продолжение ORCH-52a / ORCH-074)
|
||||
**Тип:** Багфикс (конфигурация эффорта агентов)
|
||||
**Приоритет:** HIGH
|
||||
**Repo:** orchestrator (self-hosting)
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
При проверке ORCH-074 (08.06) обнаружено: `resolve_agent_effort()` для **всех 6 агентов
|
||||
в проде** возвращает пустую строку `''`, хотя в `src/config.py` заданы осмысленные
|
||||
дефолты (`agent_effort_default="high"`, per-agent `high`/`medium`). Итог: флаг
|
||||
`--effort` **не передаётся** в Claude CLI, и каждый агент бежит на встроенном
|
||||
CLI-дефолте эффорта, а **не** на заявленном `high`/`medium`.
|
||||
|
||||
### Корень (диагностика)
|
||||
В проде env-переменные `ORCH_AGENT_EFFORT_DEFAULT` и
|
||||
`ORCH_AGENT_EFFORT_{ANALYST,ARCHITECT,DEVELOPER,REVIEWER,TESTER,DEPLOYER}` выставлены в
|
||||
**пустую строку** (`VAR=` без значения). Pydantic Settings трактует присутствующую
|
||||
env-переменную (даже пустую) как явное значение и **перебивает** дефолт класса:
|
||||
`agent_effort_* = ''`. В цепочке резолва (`launcher._resolve_agent_attr`):
|
||||
- per-agent `''` → falsy → пропуск (уровень 2);
|
||||
- default `''` → falsy → пропуск (уровень 3);
|
||||
- → возврат `''` (уровень 4, «без флага»).
|
||||
|
||||
Поскольку **и default тоже пуст**, привычный откат «per-agent пуст → взять default»
|
||||
не спасает: откатываться не на что. Это ключевой нюанс — фикс обязан давать каждой
|
||||
роли непустой «пол» (floor) даже когда И per-agent, И default env пусты.
|
||||
|
||||
## 2. Бизнес-ценность / зачем важно
|
||||
|
||||
Для Opus 4.8 (канон Anthropic) уровень reasoning-эффорта влияет на качество вывода
|
||||
**сильнее**, чем у прежних моделей. Coding/agentic роли (особенно `developer`) должны
|
||||
идти минимум на `high`, а `developer` — кандидат на `xhigh`. Сейчас фактически работает
|
||||
неконтролируемый CLI-дефолт → прямой удар по стратегии надёжности и предсказуемости
|
||||
качества всего конвейера (включая enduro-trails из общего инстанса).
|
||||
|
||||
## 3. Решение (бизнес-уровень)
|
||||
|
||||
Принят **вариант (c)** (решение Славы, 08.06): пустая строка эффорта трактуется как
|
||||
«не задано» и откатывается на осмысленный per-role дефолт (а не на CLI-дефолт),
|
||||
**устойчиво** к пустым env. Дополнительно — зафиксировать целевые дефолты в `config.py`
|
||||
и `.env.example`.
|
||||
|
||||
### Целевые значения эффорта (единственный апгрейд — `developer`)
|
||||
| Агент | Эффорт | Обоснование |
|
||||
|-------|--------|-------------|
|
||||
| analyst | high | intelligence-роль |
|
||||
| architect | high | intelligence-роль |
|
||||
| **developer** | **xhigh** | coding/agentic, канон Opus 4.8 → апгрейд с `high` |
|
||||
| reviewer | high | intelligence-роль |
|
||||
| tester | medium | механическая роль |
|
||||
| deployer | medium | механическая роль |
|
||||
|
||||
`developer → xhigh` — единственное изменение относительно текущих config-дефолтов;
|
||||
остальные значения подтверждают текущий замысел и фиксируются устойчиво.
|
||||
|
||||
## 4. Грабли / ограничения (из бизнес-запроса)
|
||||
|
||||
- **Хост-репо / env-правки НЕ переживают деплой**, если положены в git-managed файл
|
||||
(урок 08.06 про docker-compose + TZ). Источник правды для реальных значений —
|
||||
`.env` на хосте (gitignored), канон-шаблон — `.env.example`. Фикс обязан быть
|
||||
**code-side robust**: даже если прод-`.env` снова окажется с пустыми
|
||||
`ORCH_AGENT_EFFORT_*`, эффорт всё равно резолвится в целевые значения.
|
||||
- **Self-hosting:** правка касается инструмента, который сейчас в проде обслуживает и
|
||||
другие проекты. Прод-контейнер `orchestrator` не ронять в рамках задачи; деплой —
|
||||
через штатный `deploy-staging` → `Confirm Deploy`.
|
||||
|
||||
## 5. Не-цели
|
||||
|
||||
- НЕ трогать model-резолв (`resolve_agent_model` — сделан в ORCH-074).
|
||||
- НЕ включать G3 model-routing — все 6 агентов остаются на `claude-opus-4-8`.
|
||||
- НЕ менять значения эффорта сверх согласованных (`high`/`medium`/`xhigh` для
|
||||
developer). Иные значения — отдельное взвешенное решение.
|
||||
|
||||
## 6. Затронутые стороны
|
||||
|
||||
- Все агенты конвейера (analyst → deployer) во всех проектах общего инстанса.
|
||||
- Операторы (правка прод-`.env`), документация (README таблица, `.env.example`).
|
||||
</content>
|
||||
</invoke>
|
||||
110
docs/work-items/ORCH-081/02-trz.md
Normal file
110
docs/work-items/ORCH-081/02-trz.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 02 — ТЗ: ORCH-081 (ORCH-52h)
|
||||
|
||||
**Work Item:** ORCH-081 · **Тип:** багфикс конфигурации · **Repo:** orchestrator
|
||||
|
||||
Документ описывает ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые модули. Конкретный механизм
|
||||
(field_validator vs изменение резолвера) — на усмотрение архитектора; ниже зафиксированы
|
||||
инварианты, которым любая реализация обязана удовлетворять.
|
||||
|
||||
## 1. Задействованные модули
|
||||
|
||||
| Модуль | Роль в задаче |
|
||||
|--------|----------------|
|
||||
| `src/config.py` (`Settings`) | дефолты эффорта; устойчивость к пустому env (ядро фикса) |
|
||||
| `src/agents/launcher.py` | `resolve_agent_effort` / `_resolve_agent_attr` (цепочка резолва), `VALID_EFFORTS`, сборка `--effort` в `_spawn` |
|
||||
| `.env.example` | канон-шаблон значений эффорта по ролям |
|
||||
| `docs/architecture/README.md` | таблица «Модель и эффорт по ролям» (строки ~47–54) |
|
||||
| `CHANGELOG.md` | запись о фиксе |
|
||||
| `tests/test_resolve_agent_effort.py` | расширить кейсами пустого env |
|
||||
|
||||
## 2. Корень бага (точная механика)
|
||||
|
||||
`launcher._resolve_agent_attr` (строки ~104–114):
|
||||
```
|
||||
per_agent = getattr(settings, f"agent_effort_{agent}", "") # '' в проде -> falsy -> skip
|
||||
default = getattr(settings, "agent_effort_default", "") # '' в проде -> falsy -> skip
|
||||
return "" # уровень 4: без флага
|
||||
```
|
||||
Pydantic: `ORCH_AGENT_EFFORT_*=` (пустая строка в env) перебивает дефолт класса →
|
||||
поле `= ''`. Поскольку пустым оказывается **и** `agent_effort_default`, у резолва нет
|
||||
непустого «пола» для отката → `''` → `--effort` не передаётся.
|
||||
|
||||
## 3. Требования к фиксу (вариант c)
|
||||
|
||||
### FR-1. Непустой floor на каждую роль при пустом env
|
||||
При ЛЮБОЙ комбинации пустых `ORCH_AGENT_EFFORT_*` (включая `ORCH_AGENT_EFFORT_DEFAULT=`)
|
||||
`resolve_agent_effort(agent)` обязан вернуть целевое непустое значение для каждой из 6
|
||||
ролей:
|
||||
|
||||
| agent | результат |
|
||||
|-------|-----------|
|
||||
| analyst | `high` |
|
||||
| architect | `high` |
|
||||
| developer | `xhigh` |
|
||||
| reviewer | `high` |
|
||||
| tester | `medium` |
|
||||
| deployer | `medium` |
|
||||
|
||||
Замечание для реализации: floor должен быть **per-role**, а не единым на default —
|
||||
иначе пустой `ORCH_AGENT_EFFORT_TESTER=` снапнется на `high` вместо `medium`. Т.е.
|
||||
«пустая строка трактуется как не-задано» применяется так, чтобы каждая роль получала
|
||||
СВОЙ канонический дефолт, а не общий.
|
||||
|
||||
### FR-2. Приоритет резолва сохраняется
|
||||
Порядок не меняется: project-override (`projects_json.agent_efforts`) > per-agent env >
|
||||
default > floor. Непустой явный env/override по-прежнему ПОБЕЖДАЕТ floor (оператор может
|
||||
осознанно задать, напр., `ORCH_AGENT_EFFORT_DEVELOPER=high`, и это применится).
|
||||
|
||||
### FR-3. Валидация невалидного значения не регрессирует
|
||||
Значение вне `VALID_EFFORTS` (`low|medium|high|xhigh|max`) по-прежнему логируется
|
||||
(`logger.warning`) и **дропается** → `''` (без флага). Floor НЕ должен «спасать» явную
|
||||
опечатку (`turbo`/`ultra`) — поведение ORCH-41 сохраняется (never-break, мусор не
|
||||
уезжает в CLI).
|
||||
|
||||
### FR-4. `developer → xhigh` зафиксирован явно
|
||||
`config.py`: `agent_effort_developer` со значением `xhigh` (сейчас `high`).
|
||||
`.env.example`: `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` (сейчас `high`) + правка комментария
|
||||
про split (developer теперь xhigh, не в группе «thinking → high»).
|
||||
|
||||
### FR-5. `xhigh` принимается CLI-слоем
|
||||
Подтвердить, что `xhigh` присутствует в `VALID_EFFORTS`
|
||||
(`src/agents/launcher.py:22` — уже `frozenset({"low","medium","high","xhigh","max"})`,
|
||||
**присутствует**; добавления не требуется, только верификация тестом). Эффорт реально
|
||||
собирается в команду: `_spawn` строит `effort_flag = f"--effort {effort} "` при непустом
|
||||
`effort` (строка ~434) — путь проброса не менять, только убедиться тестом сборки флага.
|
||||
|
||||
## 4. Изменения API / схемы БД
|
||||
|
||||
- **API endpoints:** нет.
|
||||
- **Схема БД:** нет.
|
||||
- **Конфиг (env-контракт):** значения `ORCH_AGENT_EFFORT_*` неизменны по ИМЕНАМ;
|
||||
меняется лишь дефолт `developer` (high → xhigh) и устойчивость к пустым значениям.
|
||||
Обратная совместимость: непустой явный env работает 1:1 как раньше.
|
||||
|
||||
## 5. Требования к QG checks
|
||||
|
||||
Новых QG checks не требуется. Гейты конвейера не затрагиваются.
|
||||
|
||||
## 6. Артефакты pipeline (обновить в ТОМ ЖЕ PR)
|
||||
|
||||
- `src/config.py` — дефолт developer + устойчивость к пустому env.
|
||||
- `src/agents/launcher.py` — если фикс кладётся в резолвер (на усмотрение архитектора).
|
||||
- `.env.example` — `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + правка комментария split.
|
||||
- `docs/architecture/README.md` — таблица эффорта: developer `high` → `xhigh`; при
|
||||
необходимости — ремарка про floor/устойчивость к пустому env.
|
||||
- `CHANGELOG.md` — запись (`fix:`).
|
||||
- `tests/test_resolve_agent_effort.py` — новые кейсы (см. 04-test-plan.yaml).
|
||||
|
||||
## 7. Операционная часть (вне PR-кода, для деплой-лога)
|
||||
|
||||
- Реальные значения — в прод-`.env` на хосте (gitignored). Рекомендуется привести
|
||||
прод-`.env` к каноне `.env.example` (developer=xhigh, остальные непустые), НО фикс
|
||||
обязан работать и без этого (FR-1). Не коммитить секреты/хост-env в git.
|
||||
- Деплой — через `deploy-staging` (8501) → `Confirm Deploy`. Прод-контейнер не ронять
|
||||
вне штатного хука.
|
||||
|
||||
## 8. Definition of Done
|
||||
|
||||
AC-1…AC-5 из `03-acceptance-criteria.md` выполнены; `pytest -q` зелёный; документация
|
||||
(README + `.env.example` + CHANGELOG) синхронизирована в том же PR; never-break соблюдён.
|
||||
</content>
|
||||
60
docs/work-items/ORCH-081/03-acceptance-criteria.md
Normal file
60
docs/work-items/ORCH-081/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 03 — Критерии приёмки: ORCH-081 (ORCH-52h)
|
||||
|
||||
Каждый критерий — чёткое условие PASS/FAIL. Пустой env моделируется в unit-тестах
|
||||
(установка `agent_effort_* = ""`), проверка «в проде» — операционная (post-deploy).
|
||||
|
||||
## AC-1 — осмысленный непустой эффорт для всех 6 агентов
|
||||
**PASS:** `resolve_agent_effort(agent)` возвращает целевое непустое значение для каждой
|
||||
роли при канонической конфигурации:
|
||||
|
||||
| agent | ожидаемое |
|
||||
|-------|-----------|
|
||||
| analyst | `high` |
|
||||
| architect | `high` |
|
||||
| developer | `xhigh` |
|
||||
| reviewer | `high` |
|
||||
| tester | `medium` |
|
||||
| deployer | `medium` |
|
||||
|
||||
**FAIL:** любой агент возвращает `''` или значение, отличное от таблицы.
|
||||
|
||||
## AC-2 — пустой env НЕ приводит к пустому эффорту (вариант c)
|
||||
**PASS:** при `agent_effort_default = ""` И всех `agent_effort_<role> = ""`
|
||||
(моделирование прод-env, где `ORCH_AGENT_EFFORT_*=` пусты) `resolve_agent_effort` для
|
||||
каждой из 6 ролей возвращает значение по таблице AC-1 (floor per-role срабатывает:
|
||||
developer=`xhigh`, tester/deployer=`medium`, остальные=`high`), а **не** `''`.
|
||||
**FAIL:** хотя бы одна роль при полностью пустом env даёт `''`.
|
||||
|
||||
## AC-3 — эффорт реально пробрасывается в запуск агента
|
||||
**PASS:** в `launcher._spawn` (или эквивалентной сборке) при непустом резолвнутом
|
||||
эффорте формируется `--effort <value> ` во флагах команды; при пустом — флаг
|
||||
отсутствует. Тест сборки флага подтверждает наличие `--effort xhigh ` для developer и
|
||||
`--effort medium ` для tester.
|
||||
**FAIL:** `--effort` отсутствует при непустом значении ИЛИ присутствует при пустом.
|
||||
|
||||
## AC-4 — документация синхронизирована
|
||||
**PASS:** `.env.example` содержит `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` и корректный
|
||||
комментарий про split; таблица «Модель и эффорт по ролям» в
|
||||
`docs/architecture/README.md` показывает developer = `xhigh` (остальные без изменений);
|
||||
`CHANGELOG.md` содержит запись о фиксе.
|
||||
**FAIL:** любой из трёх артефактов рассинхронизирован с фактическими дефолтами config.
|
||||
|
||||
## AC-5 — never-break, тесты зелёные
|
||||
**PASS:**
|
||||
- `pytest -q` целиком зелёный (включая существующие
|
||||
`tests/test_resolve_agent_effort.py` и новые кейсы).
|
||||
- Невалидное значение эффорта (`turbo`/`ultra`/`bogus`) по-прежнему логируется и
|
||||
дропается в `''` (floor его НЕ маскирует) — регрессии валидации ORCH-41 нет.
|
||||
- Непустой явный per-agent env / project-override по-прежнему побеждает floor
|
||||
(приоритет резолва сохранён).
|
||||
- `xhigh ∈ VALID_EFFORTS` (подтверждено тестом).
|
||||
|
||||
**FAIL:** падение любого теста, регрессия валидации/приоритета, либо `xhigh`
|
||||
отвергается как невалидный.
|
||||
|
||||
## AC-6 (операционный, для деплой-стадии) — проверка в проде
|
||||
**PASS:** после деплоя на проде `resolve_agent_effort` для 6 агентов даёт значения
|
||||
AC-1 (проверяется в рантайме прод-инстанса / по логам запуска агента — наличие
|
||||
`--effort` с верным уровнем). Фиксируется в `14-deploy-log.md`.
|
||||
**FAIL:** в проде хотя бы один агент бежит без `--effort` или с неверным уровнем.
|
||||
</content>
|
||||
86
docs/work-items/ORCH-081/04-test-plan.yaml
Normal file
86
docs/work-items/ORCH-081/04-test-plan.yaml
Normal file
@@ -0,0 +1,86 @@
|
||||
work_item: ORCH-081
|
||||
description: >
|
||||
Тест-план фикса ORCH-52h — устойчивость резолва эффорта к пустому env (вариант c) +
|
||||
фиксация целевых дефолтов (developer -> xhigh). Расширяет существующий
|
||||
tests/test_resolve_agent_effort.py. Пустой прод-env моделируется установкой
|
||||
agent_effort_* = "" на settings (через monkeypatch), как уже делают текущие тесты.
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: >
|
||||
Канонические дефолты: resolve_agent_effort для всех 6 ролей даёт
|
||||
analyst/architect/reviewer=high, developer=xhigh, tester/deployer=medium.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-1, FR-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: >
|
||||
Пустой env (вариант c): при agent_effort_default="" И всех
|
||||
agent_effort_<role>="" каждая из 6 ролей возвращает целевое значение по AC-1
|
||||
(НЕ ""). Ключевой кейс бага: developer -> xhigh, tester/deployer -> medium,
|
||||
analyst/architect/reviewer -> high.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
Floor НЕ маскирует опечатку: невалидное значение (default/per-agent/override =
|
||||
'turbo'/'ultra'/'bogus') по-прежнему логируется и дропается в "" (валидация
|
||||
ORCH-41 не регрессирует). Проверить, что floor не подменяет невалидный явный ввод
|
||||
на дефолт.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5, FR-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
Приоритет сохранён: непустой per-agent env побеждает floor/ default
|
||||
(ORCH_AGENT_EFFORT_DEVELOPER=high -> "high", не "xhigh"); project-override
|
||||
побеждает per-agent (agent_efforts={"developer":"xhigh"}).
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5, FR-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
xhigh валиден: xhigh ∈ VALID_EFFORTS и resolve_agent_effort с developer-дефолтом
|
||||
xhigh не дропается.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5, FR-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: >
|
||||
Сборка флага: при resolve developer=xhigh во флагах присутствует "--effort xhigh ",
|
||||
при tester=medium — "--effort medium "; при пустом эффорте "--effort" отсутствует
|
||||
(mirror логики _spawn, как существующие test_flags_* кейсы).
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: >
|
||||
Документация синхронизирована: .env.example содержит
|
||||
ORCH_AGENT_EFFORT_DEVELOPER=xhigh; README таблица эффорта показывает developer
|
||||
xhigh. (Проверяется ревьюером/тестером по diff; опционально — текстовая ассерта.)
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: >
|
||||
Регрессия существующего набора: весь tests/test_resolve_agent_effort.py +
|
||||
tests/test_resolve_agent_model.py остаются зелёными (never-break ORCH-41/074).
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
</content>
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR-001: Per-role floor для резолва `--effort`, устойчивый к пустому env
|
||||
|
||||
**Work Item:** ORCH-081 (ORCH-52h) · **Эпик:** ORCH-052 (после ORCH-074)
|
||||
**Связанные:** ORCH-41 (резолв model/effort), ORCH-074 (валидация модели, `is_valid_model`)
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
В проде `resolve_agent_effort()` возвращает `''` для всех 6 агентов, хотя в
|
||||
`src/config.py` заданы осмысленные дефолты (`high`/`medium`). Итог: флаг `--effort`
|
||||
не передаётся в Claude CLI, каждый агент бежит на встроенном CLI-дефолте, а не на
|
||||
заявленном уровне. Для Opus 4.8 reasoning-эффорт сильнее влияет на качество, чем у
|
||||
прежних моделей, → прямой удар по предсказуемости качества всего конвейера (включая
|
||||
enduro-trails из общего инстанса).
|
||||
|
||||
### Корень (точная механика)
|
||||
Pydantic Settings трактует **присутствующую** env-переменную — даже пустую
|
||||
(`ORCH_AGENT_EFFORT_DEVELOPER=` без значения) — как явное значение и **перебивает**
|
||||
дефолт класса: поле `= ''`. В проде пусты И per-agent (`ORCH_AGENT_EFFORT_<ROLE>=`),
|
||||
И default (`ORCH_AGENT_EFFORT_DEFAULT=`). Цепочка резолва (`_resolve_agent_attr`):
|
||||
|
||||
```
|
||||
project-override (agent_efforts) → пусто
|
||||
per-agent env ('') → falsy → skip
|
||||
default ('') → falsy → skip
|
||||
→ '' (уровень 4: без флага)
|
||||
```
|
||||
|
||||
Привычный откат «per-agent пуст → взять default» не спасает: откатываться не на что —
|
||||
default тоже пуст. Нужен непустой **per-role** «пол» (floor) ниже default.
|
||||
|
||||
### Дополнительное ограничение (урок 08.06)
|
||||
Хост-правки env, положенные в git-managed файл, **не переживают деплой**. Источник
|
||||
правды реальных значений — `.env` на хосте (gitignored). Значит, фикс обязан быть
|
||||
**code-side robust**: даже если прод-`.env` снова окажется с пустыми
|
||||
`ORCH_AGENT_EFFORT_*`, эффорт всё равно резолвится в целевые значения.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### Вариант A — `field_validator` в `config.py` (coerce пустой → дефолт на уровне поля)
|
||||
Валидатор каждого `agent_effort_*` конвертирует пустую строку в канонический дефолт
|
||||
поля.
|
||||
**Отклонён:** ломает приоритет FR-2. Если per-agent поле всегда непустое, оно ВСЕГДА
|
||||
бьёт `default` (уровень 3 становится мёртвым для роли с пустым env). Сценарий: оператор
|
||||
ставит `ORCH_AGENT_EFFORT_DEFAULT=max`, per-agent оставляет пустыми — намерение «все
|
||||
роли на max», но coercion на уровне поля даст каждой роли её per-role дефолт, а не
|
||||
`max`. Floor обязан стоять **строго ниже** default, а это видно только в резолвере,
|
||||
где доступна вся цепочка приоритетов.
|
||||
|
||||
### Вариант B — explicit hardcoded map `{analyst: high, …}` в `launcher.py`
|
||||
Отдельная константа-карта per-role floor.
|
||||
**Отклонён как первичный:** вводит **второй источник правды** рядом с дефолтами
|
||||
`config.py`. Баг, который мы чиним, — это и есть дрейф/рассинхрон конфигурации;
|
||||
заводить новую поверхность дрейфа концептуально неверно (карту и config надо вручную
|
||||
держать в синхроне).
|
||||
|
||||
### Вариант C — floor в резолвере, значение = class-default поля (ПРИНЯТО)
|
||||
Floor применяется как **последний** уровень в `resolve_agent_effort`, ниже `default`,
|
||||
а его значение берётся из **декларированного class-default** соответствующего поля
|
||||
`Settings` (через `model_fields`), который пустой env НЕ может перебить.
|
||||
|
||||
## Решение
|
||||
|
||||
Фикс кладётся в `resolve_agent_effort` (`src/agents/launcher.py`), `_resolve_agent_attr`
|
||||
остаётся общим с model-резолвом и **не трогается** (floor — effort-специфичен).
|
||||
|
||||
### Цепочка резолва (новая, уровень 4 — floor)
|
||||
```
|
||||
1. project-override (projects_json.agent_efforts[agent]) — непустой побеждает
|
||||
2. per-agent env (settings.agent_effort_<agent>) — непустой побеждает
|
||||
3. global default (settings.agent_effort_default) — непустой побеждает
|
||||
4. per-role FLOOR (class-default поля agent_effort_<agent>) — НОВОЕ, непустой пол
|
||||
↓ (только если все 1–3 пусты)
|
||||
5. валидация VALID_EFFORTS → невалидное дропается в '' (ORCH-41, never-break)
|
||||
```
|
||||
|
||||
### Ключевые инварианты реализации
|
||||
- **Floor = class-default поля, а не instance-значение.** `type(settings).model_fields[f"agent_effort_{agent}"].default` возвращает декларированный дефолт (`high`/`medium`/`xhigh`), который пустой env не клобберит. Это восстанавливает значение, которое pydantic дал бы, не будь спурьозного `VAR=`. **Единый источник правды — `config.py`**: developer-апгрейд на `xhigh` делается одной правкой поля, floor подтягивается автоматически.
|
||||
- **Floor применяется ДО валидации и ТОЛЬКО при пустом резолве.** Порядок критичен для FR-3: явная опечатка (`turbo`) — непустая, поэтому floor НЕ применяется, и значение штатно дропается валидацией в `''`. Floor не маскирует мусор.
|
||||
- **Floor — строго уровень 4 (ниже default).** Непустой явный env/override/`default` по-прежнему побеждает floor (FR-2). Floor срабатывает лишь когда сконфигурировать эффорт забыли/занулили на всех уровнях.
|
||||
- **Unknown-agent fallback:** если поля `agent_effort_<agent>` нет (имя не из 6 ролей), floor деградирует на class-default `agent_effort_default` (`high`) — непустой безопасный пол, never-break.
|
||||
|
||||
### Сопутствующая правка config (FR-4)
|
||||
`config.py`: `agent_effort_developer` `high → xhigh` (канон Opus 4.8: coding/agentic роль).
|
||||
Это единственное изменение значений; остальные (`analyst/architect/reviewer=high`,
|
||||
`tester/deployer=medium`) подтверждаются и фиксируются устойчиво. Поскольку floor =
|
||||
class-default, апгрейд автоматически становится и новым floor для developer.
|
||||
|
||||
### Целевые значения (floor при полностью пустом env)
|
||||
| agent | floor |
|
||||
|-------|-------|
|
||||
| analyst | high |
|
||||
| architect | high |
|
||||
| developer | **xhigh** |
|
||||
| reviewer | high |
|
||||
| tester | medium |
|
||||
| deployer | medium |
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы**
|
||||
- Code-side robust: пустой прод-`.env` больше не обнуляет эффорт; целевые уровни
|
||||
гарантированы без зависимости от хост-правок, которые не переживают деплой.
|
||||
- Единый источник правды (`config.py`); нулевой риск дрейфа floor-карты.
|
||||
- Приоритет резолва и контракт ORCH-41 сохранены 1:1; непустой явный конфиг работает
|
||||
как раньше (полная обратная совместимость).
|
||||
- Валидация ORCH-41 не регрессирует — опечатки по-прежнему дропаются, never-break.
|
||||
|
||||
**Минусы / ограничения**
|
||||
- Лёгкая зависимость от pydantic-v2 API (`model_fields[...].default`) — публичный
|
||||
стабильный атрибут, но это связь с внутренним устройством Settings. Замокать в тестах
|
||||
тривиально.
|
||||
- «CLI-дефолт без флага» как исход для 6 штатных ролей становится недостижим — это
|
||||
намеренно: для известных ролей всегда есть непустой пол. Unknown-agent сохраняет
|
||||
безопасный непустой fallback.
|
||||
|
||||
**Не затрагивается**
|
||||
- API endpoints — нет. Схема БД — нет. QG checks / гейты конвейера — нет.
|
||||
Model-резолв (ORCH-074) — нет. Путь проброса `--effort` в `_spawn` (стр. ~434) — нет
|
||||
(только верификация тестом, FR-3/FR-5).
|
||||
|
||||
## Деплой (self-hosting)
|
||||
Правка касается инструмента, обслуживающего в проде и другие проекты. Прод-контейнер
|
||||
`orchestrator` не ронять в рамках задачи; деплой — штатно `deploy-staging` (8501) →
|
||||
`Confirm Deploy`. Рекомендуется привести прод-`.env` к каноне `.env.example`
|
||||
(developer=xhigh, остальные непустые), НО фикс обязан работать и без этого (FR-1).
|
||||
Проверка в проде (AC-6) фиксируется в `14-deploy-log.md`.
|
||||
17
docs/work-items/ORCH-081/10-tech-risks.md
Normal file
17
docs/work-items/ORCH-081/10-tech-risks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 10 — Технические риски: ORCH-081 (ORCH-52h)
|
||||
|
||||
| ID | Риск | Вероятн. | Влияние | Митигация |
|
||||
|----|------|----------|---------|-----------|
|
||||
| R-1 | **Floor маскирует опечатку.** Если floor применить ПОСЛЕ/ВМЕСТО валидации, мусорное `turbo` подменится на floor вместо дропа → регрессия never-break ORCH-41. | низк. | средн. | Floor строго ДО валидации и ТОЛЬКО при пустом резолве (значение `turbo` непустое → floor не трогается → дроп). Покрыть тестом FR-3 (опечатка → `''`). |
|
||||
| R-2 | **Floor перебивает явный конфиг.** Ошибка порядка → floor встанет выше default/per-agent и `ORCH_AGENT_EFFORT_DEFAULT=max` перестанет применяться. | низк. | средн. | Floor — строго уровень 4 (ниже default). Тест FR-2: непустой default/per-agent/override побеждает floor. |
|
||||
| R-3 | **Зависимость от pydantic-internal** `model_fields[...].default`. Будущий мажор pydantic может сменить API → floor отвалится. | низк. | низк. | Публичный стабильный атрибут pydantic v2. Тест AC-1/AC-2 поймает регрессию сразу (floor вернёт не то/пусто). Фиксируется версией pydantic в зависимостях. |
|
||||
| R-4 | **Дрейф floor vs config** при выборе hardcoded-карты. | — | — | Снят архитектурно: floor = class-default поля, единый источник правды (см. ADR-001, вариант B отклонён). |
|
||||
| R-5 | **Self-hosting:** правка резолва эффорта затрагивает запуск ВСЕХ агентов всех проектов общего инстанса; ошибка ломает конвейер enduro-trails тоже. | низк. | высок. | Обязательный `deploy-staging` (8501) перед прод-деплоем; прод-контейнер не ронять вне штатного хука; `Confirm Deploy`-гейт. Post-deploy проверка AC-6 по логам запуска агента. |
|
||||
| R-6 | **Прод-`.env` снова с пустыми `ORCH_AGENT_EFFORT_*`** после деплоя (урок 08.06: git-managed env не переживает). | средн. | низк. | Именно это и закрывает фикс (FR-1, code-side robust): эффорт резолвится в floor независимо от состояния `.env`. Приведение `.env` к каноне — рекомендация, не зависимость. |
|
||||
| R-7 | **`xhigh` не принимается CLI-слоем.** developer-апгрейд бессмыслен, если `xhigh ∉ VALID_EFFORTS`. | очень низк. | средн. | `xhigh` уже в `VALID_EFFORTS` (`launcher.py:22`); добавления не требуется — только верификация тестом (FR-5). |
|
||||
|
||||
## Сводный вывод
|
||||
Изменение локализовано в `resolve_agent_effort` + один дефолт `config.py`; не трогает
|
||||
API, схему БД, QG-гейты, model-резолв и путь проброса `--effort`. Главный остаточный
|
||||
риск — операционный (R-5, self-hosting), снимается штатным staging-гейтом. Контракт
|
||||
ORCH-41/ORCH-074 сохранён, обратная совместимость полная.
|
||||
57
docs/work-items/ORCH-081/12-review.md
Normal file
57
docs/work-items/ORCH-081/12-review.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-081
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-081 (ORCH-52h) — устойчивость резолва `--effort` к пустому env + developer→xhigh
|
||||
|
||||
## Summary
|
||||
Фикс конфигурационного бага: в проде `resolve_agent_effort()` возвращал `''` для всех 6 агентов (пустые `ORCH_AGENT_EFFORT_*=` перебивают class-default pydantic), `--effort` не доходил до Claude CLI. Решение — вариант C по ADR-001: непустой **per-role floor** уровня 4 в `resolve_agent_effort`, значение = декларированный class-default поля `agent_effort_<agent>` через `model_fields[...].default`. `developer` поднят `high→xhigh` в `config.py` (единый источник правды, floor подтягивается автоматически).
|
||||
|
||||
Реализация полностью соответствует ТЗ и ADR; вся документация синхронизирована в том же бранче; `pytest -q` — **1031 passed**.
|
||||
|
||||
## Соответствие ТЗ (FR-1…FR-5)
|
||||
- **FR-1** per-role floor при пустом env → каждая роль получает свой канон (`_agent_effort_floor`, TC-02). ✓
|
||||
- **FR-2** приоритет резолва сохранён: явный env/override/default побеждают floor (TC-04: `test_explicit_env_beats_floor`, `test_default_beats_floor`, `test_project_override_beats_floor`). ✓
|
||||
- **FR-3** валидация не регрессирует: непустая опечатка (`turbo`) не доходит до floor → дропается в `''` (TC-03 `test_floor_does_not_mask_typo`). ✓
|
||||
- **FR-4** `agent_effort_developer = "xhigh"` в `config.py`; `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + правка комментария split в `.env.example`. ✓
|
||||
- **FR-5** `xhigh ∈ VALID_EFFORTS`; сборка флага `--effort xhigh `/`--effort medium ` подтверждена (TC-05/TC-06). ✓
|
||||
|
||||
## Соответствие ADR-001
|
||||
- Floor как **строго уровень 4** ниже default, в резолвере — ✓ (вариант C, не field_validator/не hardcoded map).
|
||||
- Floor = **class-default поля** (`type(settings).model_fields[...].default`), который пустой env перебить не может — ✓.
|
||||
- `_resolve_agent_attr` (общий с model-резолвом) **не тронут** — ✓.
|
||||
- Floor применяется **ДО валидации и только при пустом резолве** — ✓.
|
||||
- Unknown-agent деградирует на class-default `agent_effort_default` (`high`) — ✓ (`test_empty_env_unknown_agent_floor_is_default`).
|
||||
- Никаких изменений API / схемы БД / QG / model-резолва / пути проброса в `_spawn` — ✓.
|
||||
|
||||
## Качество кода и тестов
|
||||
- Чистый leaf-helper, подробные docstrings, контракт never-raise соблюдён.
|
||||
- Тесты содержательные, покрывают все AC/FR (канон-дефолты, floor per-role, не-маскирование опечатки, приоритет на 3 уровнях, `xhigh`-валидность, сборка флага + негативные кейсы).
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- `tests/test_resolve_agent_effort.py:218-219` — продублирована строка `assert "--fallback-model" not in flags` в `test_flags_absent_when_model_empty`. Безвредно, можно убрать при случае.
|
||||
|
||||
## Документация
|
||||
Изменён `src/` → документация обновлена в том же бранче (доку-гейт пройден):
|
||||
- `docs/architecture/README.md` — таблица «Модель и эффорт по ролям»: developer = `xhigh`; добавлена ремарка про per-role floor / устойчивость к пустому env (AC-4). ✓
|
||||
- `.env.example` — `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + комментарий split/floor (AC-4). ✓
|
||||
- `CHANGELOG.md` — запись `fix:` с разбором корня/фикса. ✓
|
||||
- `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md` — присутствует (Accepted). ✓
|
||||
|
||||
## Примечание (вне scope ревью)
|
||||
- AC-6 — операционная проверка в проде после деплоя, фиксируется в `14-deploy-log.md` на стадии deploy. К коду PR не относится.
|
||||
- `git diff main...HEAD` показывает также код ORCH-074 (`is_valid_model`/`resolve_agent_model`) из-за устаревшего локального `main`; собственно изменения ORCH-081 — коммит `56bf303` (+ README обновлён в линии бранча). На ревью это не влияет: HEAD-состояние корректно по всем осям.
|
||||
61
docs/work-items/ORCH-081/13-test-report.md
Normal file
61
docs/work-items/ORCH-081/13-test-report.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-081
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-081 (ORCH-52h)
|
||||
|
||||
Устойчивость резолва `--effort` к пустому env (вариант c) + фиксация целевых
|
||||
дефолтов (developer → xhigh).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Repo/branch: orchestrator @ `feature/ORCH-081-orch-52h-env-config` (worktree)
|
||||
- prod `/health`: ok (8500) · staging `/health`: ok (8501) — не трогались
|
||||
- Дата: 2026-06-08
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Покрытие | Результат |
|
||||
|-------|----------|----------|-----------|
|
||||
| TC-01 | Канонические дефолты: 6 ролей дают high/high/xhigh/high/medium/medium | AC-1, FR-4 | PASS |
|
||||
| TC-02 | Пустой env (вариант c): per-role floor, developer→xhigh, tester/deployer→medium, остальные→high (НЕ "") | AC-2 | PASS |
|
||||
| TC-03 | Floor НЕ маскирует опечатку: `turbo`/`ultra`/`bogus` логируется и дропается в "" | AC-5, FR-3 | PASS |
|
||||
| TC-04 | Приоритет сохранён: непустой per-agent env / project-override побеждают floor/default | AC-5, FR-2 | PASS |
|
||||
| TC-05 | `xhigh ∈ VALID_EFFORTS` и не дропается | AC-5, FR-5 | PASS |
|
||||
| TC-06 | Сборка флага: `--effort xhigh ` (developer), `--effort medium ` (tester); пустой → флаг отсутствует | AC-3 | PASS |
|
||||
| TC-07 | Документация синхронизирована: `.env.example` DEVELOPER=xhigh, README таблица developer=xhigh | AC-4 | PASS |
|
||||
| TC-08 | Регрессия: весь набор test_resolve_agent_effort.py + полный регресс зелёные | AC-5 | PASS |
|
||||
|
||||
### Сопоставление с критериями приёмки
|
||||
- **AC-1** — `test_canonical_effort_all_roles[*]` (6 параметров) → PASS.
|
||||
- **AC-2** — `test_empty_env_falls_back_to_per_role_floor[*]` (6 параметров) + `test_empty_env_unknown_agent_floor_is_default` → PASS.
|
||||
- **AC-3** — `test_flags_present_when_configured`, `test_flags_effort_per_role`, `test_flags_absent_when_effort_empty` → PASS.
|
||||
- **AC-4** — verified по diff: `src/config.py:108` `agent_effort_developer = "xhigh"`; `.env.example:48` `ORCH_AGENT_EFFORT_DEVELOPER=xhigh`; `docs/architecture/README.md` таблица developer=`xhigh`; `CHANGELOG.md` содержит запись `fix:` → PASS.
|
||||
- **AC-5** — `test_floor_does_not_mask_typo`, `test_*_beats_floor`, `test_xhigh_is_valid`, `test_invalid_*_dropped` + полный регресс зелёный → PASS.
|
||||
- **AC-6** — операционный, вне scope стадии testing: проверяется в рантайме прода на стадии `deploy`, фиксируется в `14-deploy-log.md`.
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}`
|
||||
- `GET /status` → HTTP 200
|
||||
- `GET /queue` → HTTP 200
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Целевой файл задачи:
|
||||
```
|
||||
tests/test_resolve_agent_effort.py ... 29 passed, 1 warning in 0.36s
|
||||
```
|
||||
|
||||
Полный регресс:
|
||||
```
|
||||
........................................................................ [ 97%]
|
||||
....................... [100%]
|
||||
1031 passed, 1 warning in 27.02s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, не относится к задаче, предсуществующий.)
|
||||
|
||||
## Итог
|
||||
**PASS** — все 8 TC пройдены, критерии AC-1…AC-5 выполнены (AC-6 операционный, для стадии deploy), полный регресс `1031 passed`, smoke API зелёный. Прод/staging-контейнеры не затрагивались.
|
||||
12
docs/work-items/ORCH-081/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-081/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-081
|
||||
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-082/00-business-request.md
Normal file
7
docs/work-items/ORCH-082/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-81: конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD)
|
||||
|
||||
Work Item ID: ORCH-082
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
119
docs/work-items/ORCH-082/01-brd.md
Normal file
119
docs/work-items/ORCH-082/01-brd.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 01 — BRD: ORCH-082 (ORCH-81)
|
||||
|
||||
**Конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD)**
|
||||
|
||||
- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81»)
|
||||
- Repo: `orchestrator` (self-hosting)
|
||||
- Тип: **Багфикс / надёжность конвейера**
|
||||
- Приоритет: **HIGH** — блокирует автономный деплой
|
||||
- Зона: создание PR (reviewer/developer/deployer пути), `src/merge_gate.py`, `src/stage_engine.py` (`_handle_merge_verify`), `src/agents/launcher.py` (`_ensure_pr`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
При деплое **ORCH-074** (08.06, статус «Confirm Deploy») детерминированный finalizer
|
||||
(`run_deploy_finalizer` → под-гейт `_handle_merge_verify`) вызвал
|
||||
`merge_gate.merge_pr(repo, branch)` и получил **`ok=False` («no open PR»)**: в Gitea для
|
||||
ветки `feature/ORCH-074-…` **не существовало открытого PR** с `head.ref==branch` и
|
||||
`base.ref=="main"`.
|
||||
|
||||
Защита **ORCH-073** (fail-closed по «SHA-в-main») отработала **корректно**: задача удержана
|
||||
на стадии `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert, ложно-зелёного `done` не
|
||||
произошло. Это **правильное** поведение для случая «merge реально невозможен».
|
||||
|
||||
**Дефект не в защите, а в инварианте до неё:** автономный конвейер **не гарантировал**, что к
|
||||
моменту merge у ветки существует открытый PR. PR на сегодня создаётся ровно в одном месте —
|
||||
`launcher._ensure_pr`, вызываемом **только** на пути `agent == "developer"` и **только** когда
|
||||
в этом конкретном run был непустой git-diff, успешный commit и успешный push (см. root-cause
|
||||
ниже). Любой сценарий, где developer-run не произвёл свежий коммит, оставляет ветку **без PR**,
|
||||
и задача неминуемо застревает на merge-verify.
|
||||
|
||||
### Workaround, применённый вручную (НЕ фикс)
|
||||
PR #79 создан вручную через Gitea API (`mergeable=True`) → штатно перезапущен
|
||||
`run_deploy_finalizer` → `merge_pr` честно влил код в `main` → задача `done`. Это разовое ручное
|
||||
вмешательство, **не** устранение причины.
|
||||
|
||||
### Почему это системный пробел, а не разовый сбой
|
||||
Так как создание PR **не гарантировано конвейером**, любая следующая задача с тем же стечением
|
||||
обстоятельств (developer-run без нового коммита; тихо упавший вызов создания PR; ветка
|
||||
восстановлена/пересоздана вручную) застрянет на merge-verify тем же образом. Автономность
|
||||
деплоя (цель ORCH-54) этим заблокирована.
|
||||
|
||||
---
|
||||
|
||||
## 2. Root cause (предварительный аудит кода — подтвердить логами G1)
|
||||
|
||||
PR создаётся **исключительно** функцией `AgentLauncher._ensure_pr` (`src/agents/launcher.py`),
|
||||
которая вызывается из `_monitor_agent` по цепочке условий:
|
||||
|
||||
```
|
||||
exit_code == 0
|
||||
→ есть worktree-изменения (git status --porcelain непусто)
|
||||
→ git commit succeeded
|
||||
→ git push succeeded
|
||||
→ agent == "developer" ←── ТОЛЬКО здесь вызывается self._ensure_pr(...)
|
||||
```
|
||||
|
||||
Отсюда минимум три структурных способа остаться без PR:
|
||||
|
||||
- **R-A (условное создание).** Если developer-run завершился без изменений (`git status`
|
||||
пустой) — ветка уже была закоммичена/запушена в прошлый run, бойнс REQUEST_CHANGES без новых
|
||||
правок, повторный прогон, или ручное восстановление ветки — `_ensure_pr` **не вызывается
|
||||
вовсе**. PR не появится никогда. (Соответствует гипотезе ТЗ №2.)
|
||||
- **R-B (тихий сбой создания).** `_ensure_pr` ловит любое исключение
|
||||
(`except Exception → logger.error → return None`): транзиентная ошибка Gitea на шаге
|
||||
`POST …/pulls` теряется без ретрая и без эскалации. Конвейер «думает», что developer
|
||||
отработал, и едет дальше. (Гипотеза ТЗ №1 — silent fail.)
|
||||
- **R-C (разъехавшееся состояние ветки/PR).** ORCH-074 — первая задача после серии ручных
|
||||
восстановлений `main` 08.06. PR мог быть закрыт/пересоздан, либо у ветки остался только
|
||||
авто-docs-PR (`base != main`), который `merge_pr`/`pr_already_merged` корректно НЕ считают
|
||||
кодовым PR. (Гипотеза ТЗ №4.)
|
||||
|
||||
Идемпотентность (гипотеза №3): сам `_ensure_pr` идемпотентен на чтении (сначала `GET …open&head`,
|
||||
создаёт только если пусто), но он не запускается вне «свежий developer-коммит», поэтому
|
||||
идемпотентность не достигает merge-стадии — никакой флаг «PR создан» в БД не хранится.
|
||||
|
||||
**Вывод:** гарантия «к моменту merge у ветки есть открытый код-PR» в конвейере **отсутствует**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Бизнес-цели
|
||||
|
||||
| ID | Цель |
|
||||
|----|------|
|
||||
| **G1** | Установить и задокументировать точную причину отсутствия PR на ORCH-074 (код-аудит + логи run_id 396/398). |
|
||||
| **G2** | Гарантировать инвариант: к моменту merge-verify у ветки **есть** открытый код-PR; если его нет — finalizer/deployer создаёт его сам, **идемпотентно**, ПЕРЕД `merge_pr`, вместо HOLD на ручное вмешательство. |
|
||||
| **G3** | Явно логировать факт PR: **PR-created / PR-existed / PR-create-failed** (наблюдаемость). |
|
||||
|
||||
## 4. Не-цели (явные границы)
|
||||
|
||||
- НЕ ослаблять защиту ORCH-073: fail-closed по «SHA-в-main» остаётся. Реальная невозможность
|
||||
merge → по-прежнему HOLD + alert.
|
||||
- НЕ авто-мержить без PR (PR — обязательный артефакт ревью/слияния).
|
||||
- НЕ создавать PR в неподходящий момент — только на ребре `deploy → done`, ПОСЛЕ прохождения
|
||||
всех гейтов (security/merge-gate/staging/image-freshness уже пройдены).
|
||||
- НЕ менять `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схему БД, контракты `check_deploy_status`,
|
||||
exit-коды хука.
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
- **Owner** (homenet542) — автономность деплоя орка.
|
||||
- Все проекты на инстансе (enduro-trails) — общий прод/очередь: ложный HOLD self-задачи не
|
||||
должен требовать ручного вмешательства, а реальный дефект merge — обязан удерживаться.
|
||||
|
||||
## 6. Бизнес-риски и допущения
|
||||
- **Грабли (из ORCH-073):** у ветки может быть несколько PR (код-PR + авто docs-PR). Создание/
|
||||
выбор PR обязан фильтровать `head.ref==branch` И `base.ref=="main"`, иначе слияние/верификация
|
||||
схватят не тот PR.
|
||||
- **Допущение:** merge-verify исполняется ПОСЛЕ всех гейтов, поэтому создание PR именно здесь не
|
||||
обходит ревью и безопасно по времени.
|
||||
- **Контракт надёжности:** весь новый путь — **never-raise**; ошибка создания PR (Gitea
|
||||
недоступна) → честный HOLD + alert, а не исключение в `advance_stage`.
|
||||
|
||||
## 7. Definition of Done (бизнес-уровень)
|
||||
1. Root cause задокументирован (`06-adr/` архитектором, ссылка из ADR на этот BRD).
|
||||
2. После фикса задача с веткой без PR не зависает: конвейер создаёт PR идемпотентно и доводит до
|
||||
`done` (при честном merge).
|
||||
3. Защита ORCH-073 цела (регресс-тест на «код не в main» → HOLD).
|
||||
4. Логи различают created/existed/failed.
|
||||
5. `pytest` зелёный; never-raise соблюдён.
|
||||
108
docs/work-items/ORCH-082/02-trz.md
Normal file
108
docs/work-items/ORCH-082/02-trz.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 02 — ТЗ: ORCH-082 (ORCH-81)
|
||||
|
||||
**Гарантированный идемпотентный код-PR перед merge-verify + наблюдаемость**
|
||||
|
||||
> Машина стадий, реестр `QG_CHECKS`, схема БД, exit-коды хука, контракты
|
||||
> `check_deploy_status`/`_parse_deploy_status`, защита ORCH-073 (SHA-в-main) — **НЕ меняются**.
|
||||
> Изменение — точечная врезка «ensure PR» в под-гейт merge-verify + новый идемпотентный
|
||||
> PR-актор в `merge_gate` + структурное логирование.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче | Характер изменения |
|
||||
|--------|---------------|--------------------|
|
||||
| `src/merge_gate.py` | leaf-логика merge-актора (`merge_pr`, `verify_merged_to_main`, `pr_already_merged`) | **+ новый идемпотентный актор** `ensure_open_pr(repo, branch) -> (status, detail)` (never-raise). |
|
||||
| `src/stage_engine.py` | под-гейт `_handle_merge_verify` на ребре `deploy → done` | **врезка:** вызвать `ensure_open_pr` ПЕРЕД `merge_pr`; на `failed` → честный HOLD+alert; логировать исход. |
|
||||
| `src/agents/launcher.py` | `_ensure_pr` (текущий единственный создатель PR) | **усилить наблюдаемость** (различать created/existed/failed) — опционально переиспользовать новый актор `merge_gate.ensure_open_pr`, чтобы создание PR было единым кодом. Поведение «создавать только у developer» НЕ ужесточать без необходимости. |
|
||||
| `src/config.py` | флаги | **+ kill-switch** `merge_verify_autocreate_pr_enabled` (дефолт `True`), область — та же `merge_verify_applies` (self-hosting / `merge_verify_repos`). |
|
||||
| `docs/architecture/README.md`, `CHANGELOG.md` | golden source | обновить (раздел ORCH-071/073 merge-verify — дописать про авто-создание PR). |
|
||||
|
||||
> Точная сигнатура `ensure_open_pr`, имя/дефолт kill-switch и место врезки — за архитектором
|
||||
> (ADR). Ниже — функциональные требования к поведению, не финальный дизайн.
|
||||
|
||||
## 2. Функциональные требования
|
||||
|
||||
### FR-1 — Идемпотентный PR-актор `merge_gate.ensure_open_pr(repo, branch)`
|
||||
Возвращает структурированный исход (например `("existed"|"created"|"failed", detail)`):
|
||||
1. `GET …/pulls?state=open` → если есть PR с **`head.ref==branch` И `base.ref=="main"`** →
|
||||
`("existed", <number>)`. **Фильтр идентичен `merge_pr`/ORCH-073 FR-3** — авто-docs-PR
|
||||
(`base != main`) НЕ считается код-PR.
|
||||
2. Иначе `POST …/pulls` (`head=branch`, `base=main`, заголовок/тело — авто) → `201` →
|
||||
`("created", <number>)`.
|
||||
3. Идемпотентность: если параллельно PR уже создан и Gitea вернёт ошибку «PR exists» —
|
||||
повторный `GET` подтверждает существующий PR и возвращает `("existed", …)`, **дубль не
|
||||
плодится** (AC-2).
|
||||
4. Любая иная ошибка HTTP/parse/сети → `("failed", <reason>)`. **Never-raise.**
|
||||
|
||||
### FR-2 — Врезка в `_handle_merge_verify` (ребро `deploy → done`)
|
||||
Внутри существующего `_handle_merge_verify`, ПОСЛЕ `merge_verify_applies(repo)`-гейта и
|
||||
резолва `validated_revision`, но **ПЕРЕД** `merge_pr`:
|
||||
- если `merge_verify_autocreate_pr_enabled` → вызвать `ensure_open_pr(repo, branch)`;
|
||||
- `status == "created"|"existed"` → продолжить штатно к `merge_pr` → `verify_merged_to_main`;
|
||||
- `status == "failed"` → **честный HOLD + alert** (как сегодняшний not-merged путь:
|
||||
`note_not_merged_alert` + `set_issue_blocked` + Plane-коммент + Telegram; задача остаётся на
|
||||
`deploy`, НЕ `done`, БЕЗ отката на development) с сообщением, отражающим «PR создать не
|
||||
удалось» (а не «PR не влит»).
|
||||
- kill-switch off → текущее поведение 1:1 (никакого создания PR).
|
||||
|
||||
### FR-3 — Защита ORCH-073 цела (регресс-инвариант)
|
||||
Создание PR **не подменяет** проверку слияния. После `ensure_open_pr` + `merge_pr` верификация
|
||||
остаётся **только** `verify_merged_to_main` (SHA-в-main, ORCH-073 FR-1) + регресс-гард
|
||||
(`check_main_regression`). Если код реально не оказался в `main` — HOLD сохраняется. Создание PR
|
||||
лишь устраняет **ложный** HOLD «no open PR», который конвейер обязан был предотвратить.
|
||||
|
||||
### FR-4 — Наблюдаемость (G3)
|
||||
В лог писать однозначный исход на каждом из мест работы с PR:
|
||||
- `merge-verify ensure_open_pr -> created PR #N` /
|
||||
- `… -> existed PR #N` /
|
||||
- `… -> failed: <reason>`.
|
||||
Сообщение HOLD при `failed` обязано отличаться текстом от HOLD «not merged» (оператор должен
|
||||
видеть, что причина — невозможность создать PR, а не невозможность слить уже созданный).
|
||||
Желательно — пометка исхода в `14-deploy-log.md` (best-effort, frontmatter `deploy_status:`
|
||||
нетронут).
|
||||
|
||||
### FR-5 — Идемпотентность повторного прохода
|
||||
Повторный заход в merge-verify (reaper / reconciler / повторный approve) при уже существующем
|
||||
PR → `ensure_open_pr` возвращает `("existed", …)`, `merge_pr` → `already-merged`/штатно — **без
|
||||
дублей PR и без побочных эффектов** (INV-5/AC-9 ORCH-073 сохранены).
|
||||
|
||||
## 3. Изменения API (HTTP / внутренние)
|
||||
- **Внешний HTTP API сервиса — без изменений** (новых endpoint нет).
|
||||
- **Исходящие вызовы Gitea:** новый `POST /api/v1/repos/{owner}/{repo}/pulls` из контекста
|
||||
merge-verify (тот же вызов, что уже делает `_ensure_pr`); чтение — существующий
|
||||
`GET …/pulls?state=open`.
|
||||
- **Внутренний контракт `merge_gate`:** новая публичная функция `ensure_open_pr` (leaf,
|
||||
never-raise), вызывается из `stage_engine._handle_merge_verify` (и опционально из
|
||||
`launcher._ensure_pr`).
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
**Нет.** Состояние идемпотентности выводится из самого Gitea (наличие открытого PR), миграции
|
||||
не требуются. (Согласуется с restart-safe-моделью merge-verify.)
|
||||
|
||||
## 5. Требования к новым QG checks
|
||||
**Новых зарегистрированных QG-checks нет.** Это под-гейт-врезка в `advance_stage`
|
||||
(`_handle_merge_verify`), как и сам ORCH-071 merge-verify — не отдельный `QG_CHECKS`-элемент.
|
||||
Реестр `QG_CHECKS` не трогается.
|
||||
|
||||
## 6. Конфигурация / kill-switch
|
||||
- `merge_verify_autocreate_pr_enabled: bool = True` (env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`).
|
||||
`False` → ровно прежнее поведение (нет авто-создания PR; «no open PR» → HOLD как раньше).
|
||||
- Область действия — `merge_gate.merge_verify_applies(repo)`: реально только для self-hosting /
|
||||
`merge_verify_repos`; прочие репо — no-op.
|
||||
|
||||
## 7. Артефакты pipeline (создать/обновить)
|
||||
- `docs/work-items/ORCH-082/06-adr/ADR-001-*.md` — архитектор (root cause G1 + дизайн ensure-PR).
|
||||
- `12-review.md`, `13-test-report.md`, `14/15/16-*` — последующие стадии.
|
||||
- Обновить `docs/architecture/README.md` (блок ORCH-071/073) и `CHANGELOG.md` — в ТОМ ЖЕ PR
|
||||
(правило агентов №2/№6).
|
||||
|
||||
## 8. Инварианты (не нарушать)
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`,
|
||||
exit-коды хука, terminal-sync, merge-gate (ORCH-043), image-freshness (ORCH-058) — **без
|
||||
изменений**.
|
||||
- Контракт **never-raise** на всём пути merge-verify (INV-1 ORCH-073).
|
||||
- Слияние только через PR (`POST /pulls/{index}/merge`); `main` никогда не push/force-push.
|
||||
- Защита ORCH-073 (SHA-в-main + регресс-гард) приоритетна: при конфликте «создать PR» проигрывает
|
||||
«не дать ложно-зелёный done».
|
||||
69
docs/work-items/ORCH-082/03-acceptance-criteria.md
Normal file
69
docs/work-items/ORCH-082/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 03 — Критерии приёмки: ORCH-082 (ORCH-81)
|
||||
|
||||
Каждый критерий — однозначное условие PASS/FAIL. Машинные вердикты гейтов — только из
|
||||
YAML-frontmatter.
|
||||
|
||||
---
|
||||
|
||||
### AC-1 — Root cause задокументирован
|
||||
- **PASS:** в `06-adr/ADR-001-*.md` зафиксировано, **почему** PR не создался на ORCH-074
|
||||
(со ссылкой на код-путь `launcher._ensure_pr` и/или логи run_id 396/398), и какая из гипотез
|
||||
R-A/R-B/R-C подтвердилась.
|
||||
- **FAIL:** причина не названа / только догадка без привязки к коду или логам.
|
||||
|
||||
### AC-2 — Гарантированный идемпотентный код-PR к merge-verify
|
||||
- **PASS:** к моменту merge-verify у ветки гарантированно существует открытый PR с
|
||||
`head.ref==branch` И `base.ref=="main"`; повторный вызов авто-создания при уже существующем PR
|
||||
**не плодит дубль** (возвращает existed).
|
||||
- **FAIL:** при отсутствии PR задача сразу уходит в HOLD; ИЛИ повторный проход создаёт второй PR.
|
||||
|
||||
### AC-3 — Авто-создание PR ПЕРЕД merge_pr (вместо немедленного HOLD)
|
||||
- **PASS:** при физическом отсутствии открытого код-PR `_handle_merge_verify` сначала создаёт PR
|
||||
(`ensure_open_pr → created`), затем выполняет `merge_pr` → `verify_merged_to_main`; ложного
|
||||
HOLD «no open PR» не возникает.
|
||||
- **FAIL:** «no open PR» по-прежнему приводит к HOLD без попытки создать PR (при включённом
|
||||
kill-switch).
|
||||
|
||||
### AC-4 — Защита ORCH-073 цела (регресс)
|
||||
- **PASS:** при реальном «код не в `main`» (`verify_merged_to_main → False`) — по-прежнему HOLD +
|
||||
alert + `set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на development. Регресс-гард
|
||||
`check_main_regression` не ослаблен.
|
||||
- **FAIL:** создание PR маскирует невлитый код и пропускает задачу в `done`; ИЛИ ослаблен
|
||||
SHA-в-main / регресс-гард.
|
||||
|
||||
### AC-5 — Логи различают исход PR
|
||||
- **PASS:** в логах присутствует ровно один однозначный исход на проход: **PR-created** /
|
||||
**PR-existed** / **PR-create-failed**; HOLD по «create-failed» текстуально отличим от HOLD
|
||||
«not merged».
|
||||
- **FAIL:** исход не логируется или created/existed/failed неразличимы.
|
||||
|
||||
### AC-6 — Грабли мультиPR: фильтр base==main
|
||||
- **PASS:** при наличии у ветки авто-docs-PR (`base != main`) актор НЕ принимает его за код-PR и
|
||||
создаёт/выбирает именно PR на `main`.
|
||||
- **FAIL:** docs-PR трактуется как код-PR (слияние/верификация работают не с тем PR).
|
||||
|
||||
### AC-7 — Never-raise + честный HOLD при недоступности Gitea
|
||||
- **PASS:** при ошибке создания PR (Gitea недоступна/HTTP-ошибка) `ensure_open_pr` возвращает
|
||||
`failed`, путь merge-verify даёт честный HOLD+alert, исключение НЕ всплывает в `advance_stage`.
|
||||
- **FAIL:** исключение пробрасывается / процесс падает / задача молча уходит в `done`.
|
||||
|
||||
### AC-8 — Kill-switch off → прежнее поведение 1:1
|
||||
- **PASS:** при `merge_verify_autocreate_pr_enabled=False` авто-создание не выполняется; «no open
|
||||
PR» → HOLD как до фикса (поведение ORCH-074 воспроизводится).
|
||||
- **FAIL:** при выключенном флаге PR всё равно создаётся.
|
||||
|
||||
### AC-9 — Условность (область self-hosting)
|
||||
- **PASS:** для не-self репозиториев (`merge_verify_applies → False`) врезка — no-op; создание PR
|
||||
остаётся за прежним механизмом.
|
||||
- **FAIL:** авто-создание срабатывает для чужих репо.
|
||||
|
||||
### AC-10 — Инварианты не нарушены
|
||||
- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`, exit-коды хука,
|
||||
merge-gate/image-freshness — без изменений; `main` не push/force-push; документация
|
||||
(`README.md`, `CHANGELOG.md`) обновлена в этом же PR.
|
||||
- **FAIL:** затронут любой из перечисленных инвариантов / документация не обновлена.
|
||||
|
||||
### AC-11 — pytest зелёный
|
||||
- **PASS:** `pytest tests/ -q` зелёный, включая новые тесты из `04-test-plan.yaml` и
|
||||
существующие `test_merge_verify*.py` / `test_orch073_*` / `test_merge_actor.py`.
|
||||
- **FAIL:** любой тест падает.
|
||||
90
docs/work-items/ORCH-082/04-test-plan.yaml
Normal file
90
docs/work-items/ORCH-082/04-test-plan.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
work_item: ORCH-082
|
||||
title: "Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD)"
|
||||
strategy: >
|
||||
Юнит-тесты на новый идемпотентный актор merge_gate.ensure_open_pr (мок Gitea HTTP)
|
||||
и интеграционные тесты на врезку в stage_engine._handle_merge_verify (мок merge_gate
|
||||
+ verify), включая регресс ORCH-073. Все пути — never-raise. Gitea и git мокаются,
|
||||
сеть не дёргается.
|
||||
|
||||
tests:
|
||||
# ---- ensure_open_pr: идемпотентный PR-актор (FR-1) ----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "ensure_open_pr: открытого код-PR нет -> POST создаёт PR -> ('created', N); фильтр base==main применён"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "ensure_open_pr: открытый PR head==branch И base==main уже есть -> ('existed', N), POST не вызывается (нет дубля)"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Грабли мультиPR: у ветки только docs-PR (base!=main) -> он НЕ считается код-PR -> создаётся PR на main (AC-6)"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "ensure_open_pr never-raise: Gitea POST/GET кидает HTTP/timeout -> ('failed', reason), исключение не всплывает (AC-7)"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Идемпотентность гонки: POST вернул 'PR exists' -> повторный GET подтверждает существующий -> ('existed', N), дубль не создан"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
# ---- _handle_merge_verify: врезка ensure-PR (FR-2/FR-3) ----
|
||||
- id: TC-06
|
||||
type: integration
|
||||
description: "merge-verify: PR отсутствовал -> ensure_open_pr создаёт -> merge_pr -> verify True -> deploy->done БЕЗ ложного HOLD (AC-3)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: "Регресс ORCH-073: PR создан/влит, но verify_merged_to_main=False (код не в main) -> HOLD + set_issue_blocked, НЕ done, без отката (AC-4)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: integration
|
||||
description: "ensure_open_pr -> 'failed' (Gitea down) -> честный HOLD+alert, текст отличается от 'not merged', advance_stage не падает (AC-7)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: "Kill-switch merge_verify_autocreate_pr_enabled=False -> ensure_open_pr не вызывается, 'no open PR' -> прежний HOLD 1:1 (AC-8)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: integration
|
||||
description: "Условность: non-self репо (merge_verify_applies=False) -> врезка no-op, авто-создание не выполняется (AC-9)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "Идемпотентный повторный проход (reaper/reconciler): PR уже existed, merge_pr=already-merged -> verify True -> done, без дублей PR (AC-2/FR-5)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Наблюдаемость (G3 / AC-5) ----
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Логи различают created/existed/failed; HOLD-сообщение create-failed != HOLD-сообщение not-merged (caplog, AC-5)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Регресс существующего merge-verify контракта ----
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Happy-path ORCH-071/073 не изменён: merge_pr ok + verify True + регресс-гард ok -> done, merged_to_main: true во frontmatter"
|
||||
module: tests/test_merge_verify.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,221 @@
|
||||
# ADR-001: Гарантированный идемпотентный код-PR перед merge-verify (ensure_open_pr)
|
||||
|
||||
- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81»)
|
||||
- Repo: `orchestrator` (self-hosting)
|
||||
- Связь: амендмент к merge-verify ([adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md),
|
||||
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md));
|
||||
глобально зафиксировано в [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md)
|
||||
- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
### Что случилось (инцидент ORCH-074, 08.06)
|
||||
Деплой ORCH-074 встал на под-гейте merge-verify (ребро `deploy → done`):
|
||||
`run_deploy_finalizer → _handle_merge_verify` вызвал `merge_gate.merge_pr(repo, branch)` и
|
||||
получил `ok=False, "no open PR"` — в Gitea для ветки `feature/ORCH-074-…` **не было открытого
|
||||
PR** с `head.ref==branch` И `base.ref=="main"`. Защита ORCH-073 (fail-closed по «SHA-в-main»)
|
||||
**отработала правильно**: задача удержана на `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert,
|
||||
ложно-зелёного `done` не произошло. Разблокировано вручную — PR #79 создан через Gitea API,
|
||||
finalizer перезапущен, код честно влит, задача `done`. Это **workaround, не фикс**.
|
||||
|
||||
### Root cause (G1, подтверждён код-аудитом)
|
||||
PR создаётся в конвейере **ровно в одном месте** — `AgentLauncher._ensure_pr`
|
||||
(`src/agents/launcher.py:1079`), и вызывается он из `_monitor_agent` **только** по цепочке
|
||||
условий (`src/agents/launcher.py:751–753`):
|
||||
|
||||
```
|
||||
exit_code == 0
|
||||
→ git status --porcelain непусто (есть worktree-изменения)
|
||||
→ git commit succeeded
|
||||
→ git push succeeded
|
||||
→ agent == "developer" ←── ТОЛЬКО здесь вызывается self._ensure_pr(...)
|
||||
```
|
||||
|
||||
Отсюда класс «ветка без PR» структурно неизбежен. Подтверждённые код-аудитом ветви:
|
||||
|
||||
- **R-A (условное создание) — структурный первопричинный дефект.** Если в конкретном
|
||||
developer-run нет свежих изменений (`git status` пуст: ветка уже была закоммичена/запушена
|
||||
ранее, бойнс REQUEST_CHANGES без новых правок, повторный прогон, **ручное восстановление
|
||||
ветки**) — `_ensure_pr` **не вызывается вовсе**. PR не появится никогда. Никакого
|
||||
персистентного флага «PR создан» в БД нет, поэтому идемпотентность чтения внутри `_ensure_pr`
|
||||
до merge-стадии не доходит.
|
||||
- **R-C (разъехавшееся состояние ветки/PR) — проксимальный триггер ORCH-074.** ORCH-074 — первая
|
||||
задача после серии **ручных восстановлений `main` 08.06**: открытый код-PR был закрыт/не
|
||||
пересоздан, у ветки мог остаться лишь авто-docs-PR (`base != main`), который `merge_pr` (фильтр
|
||||
`base=="main"`, ORCH-073 FR-3) корректно НЕ считает кодовым.
|
||||
- **R-B (тихий сбой создания) — потенциальная, не первопричина здесь.** `_ensure_pr` глотает любое
|
||||
исключение (`except Exception → logger.error → return None`): транзиентная ошибка Gitea на
|
||||
`POST …/pulls` теряется без ретрая и эскалации.
|
||||
|
||||
**Вывод:** в конвейере **отсутствует инвариант** «к моменту merge-verify у ветки есть открытый
|
||||
код-PR». Защита ORCH-073 верно ловит следствие, но причина — выше по потоку. Любая следующая
|
||||
задача с тем же стечением обстоятельств застрянет тем же образом → автономный деплой (ORCH-54)
|
||||
заблокирован.
|
||||
|
||||
### Ограничения, которые нельзя нарушать
|
||||
- Защита ORCH-073 (SHA-в-main + регресс-гард) — приоритетна. Создание PR **не должно** маскировать
|
||||
реально невлитый код.
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`,
|
||||
exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений.
|
||||
- Весь путь merge-verify — **never-raise**.
|
||||
- Слияние только через PR; `main` никогда не push/force-push.
|
||||
|
||||
## Решение
|
||||
|
||||
Закрыть пробел инвариантом «обеспечить открытый код-PR» **внутри того же под-гейта merge-verify**,
|
||||
ПЕРЕД детерминированным `merge_pr`. Три точечные врезки, симметричные существующему дизайну
|
||||
ORCH-071/073 (leaf-актор в `merge_gate` + врезка в `_handle_merge_verify` + kill-switch). Машина
|
||||
стадий и реестры не трогаются.
|
||||
|
||||
### Р-1. Новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch)`
|
||||
|
||||
Сигнатура (решение архитектора по ТЗ §1):
|
||||
|
||||
```python
|
||||
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
|
||||
"""Гарантировать открытый код-PR (head==branch, base==main). never-raise.
|
||||
Возврат: ("existed", "<number>") | ("created", "<number>") | ("failed", "<reason>").
|
||||
"""
|
||||
```
|
||||
|
||||
Алгоритм (FR-1):
|
||||
1. `GET …/pulls?state=open` → найти PR с **`head.ref==branch` И `base.ref=="main"`**. Фильтр
|
||||
**идентичен** `merge_pr`/ORCH-073 FR-3 — авто-docs-PR (`base != main`) НЕ считается код-PR
|
||||
(AC-6). Нашли → `("existed", <number>)`.
|
||||
2. Иначе `POST …/pulls` (`head=branch`, `base="main"`, авто-заголовок/тело) → `201` →
|
||||
`("created", <number>)`.
|
||||
3. **Идемпотентность при гонке:** если на `POST` Gitea вернёт «PR exists»/`409`/`422` —
|
||||
повторный `GET` (шаг 1) подтверждает существующий PR → `("existed", …)`. Дубль не плодится
|
||||
(AC-2, FR-5).
|
||||
4. Любая иная HTTP/parse/сетевая ошибка → `("failed", <reason>)`. **Never-raise** (`except
|
||||
Exception → ("failed", str(e))`).
|
||||
|
||||
Актор — **leaf** (зависит только от `settings` + `httpx`, без импорта `stage_engine`), как
|
||||
`merge_pr`/`verify_merged_to_main`. Таймауты — переиспользовать `settings.merge_pr_timeout_s`
|
||||
(тот же класс Gitea-вызовов).
|
||||
|
||||
> **Почему фильтр `base=="main"` критичен** (грабли ORCH-073): у ветки одновременно бывают код-PR
|
||||
> и авто-docs-PR. Без фильтра актор «увидит» docs-PR как existed и не создаст нужный код-PR, а
|
||||
> `merge_pr` потом не найдёт что мержить → петля. Один и тот же предикат `head==branch &&
|
||||
> base=="main"` гарантирует, что `ensure_open_pr` и `merge_pr` работают с одним и тем же PR.
|
||||
|
||||
### Р-2. Врезка в `_handle_merge_verify` (ребро `deploy → done`)
|
||||
|
||||
В существующем `_handle_merge_verify` (`src/stage_engine.py:1324`), **ПОСЛЕ**
|
||||
`merge_verify_applies(repo)`-гейта и резолва `sha = image_freshness.validated_revision(...)`,
|
||||
но **ПЕРЕД** `merge_pr`:
|
||||
|
||||
```python
|
||||
sha = image_freshness.validated_revision(repo, branch)
|
||||
|
||||
# ORCH-082: гарантировать открытый код-PR ДО детерминированного merge_pr.
|
||||
if settings.merge_verify_autocreate_pr_enabled:
|
||||
pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(
|
||||
f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})"
|
||||
)
|
||||
if pr_status == "failed":
|
||||
return _hold_pr_create_failed(
|
||||
task_id, repo, work_item_id, branch, pr_detail, result
|
||||
)
|
||||
# "created" | "existed" -> штатно продолжаем к merge_pr.
|
||||
|
||||
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
|
||||
...
|
||||
```
|
||||
|
||||
Семантика (FR-2):
|
||||
- `created | existed` → продолжаем штатно к `merge_pr` → `verify_merged_to_main` → регресс-гард.
|
||||
- `failed` → **честный HOLD + alert** через новый helper `_hold_pr_create_failed` (см. Р-3); задача
|
||||
остаётся на `deploy` (НЕ `done`), БЕЗ отката на development — симметрично текущему not-merged/
|
||||
regressed HOLD.
|
||||
- kill-switch off → блок пропускается целиком → поведение 1:1 как до фикса (AC-8).
|
||||
|
||||
Место выбрано так, что **никакой существующий шаг не сдвигается**: `merge_pr` и
|
||||
`verify_merged_to_main` остаются на своих местах с теми же контрактами. Создание PR — это только
|
||||
страховка инварианта ДО них.
|
||||
|
||||
### Р-3. Новый HOLD-helper `_hold_pr_create_failed` (распознаваемость причины, FR-4/AC-5)
|
||||
|
||||
Зеркало существующего `_hold_main_regressed` (`src/stage_engine.py:1280`). Текст HOLD **обязан
|
||||
отличаться** от not-merged HOLD: оператор должен видеть, что причина — **невозможность создать
|
||||
PR** (Gitea недоступна), а не **невозможность слить уже созданный**:
|
||||
|
||||
```python
|
||||
def _hold_pr_create_failed(task_id, repo, work_item_id, branch, reason, result) -> bool:
|
||||
merge_gate.note_not_merged_alert(work_item_id) # переиспользуем счётчик-нотификатор
|
||||
msg = (f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, "
|
||||
f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача "
|
||||
f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR.")
|
||||
# set_issue_blocked + plane_add_comment + send_telegram (каждый в try/except, never-break HOLD)
|
||||
result.alerted = True
|
||||
result.note = "pr-create-failed-hold" # отличается от "merge-not-verified-hold"
|
||||
result.advanced = False
|
||||
return True
|
||||
```
|
||||
|
||||
Это сохраняет инвариант «никогда не пробрасываем исключение в `advance_stage`»: `failed` —
|
||||
структурированный исход, а не throw.
|
||||
|
||||
### Р-4. Единый источник кода создания PR (опционально, рекомендуется)
|
||||
|
||||
`launcher._ensure_pr` рекомендуется **делегировать** в `merge_gate.ensure_open_pr`, чтобы создание
|
||||
PR жило в одном месте и одинаково логировало created/existed/failed (G3). **Поведенческий
|
||||
инвариант:** триггер «создавать PR только в developer-пути со свежим коммитом» **НЕ ужесточается**
|
||||
(BRD/ТЗ §1) — меняется лишь реализация под капотом, не условие вызова. Это снижает риск
|
||||
рассинхрона двух копий логики «выбрать/создать PR». Если делегирование увеличивает диффу/риск —
|
||||
допустимо оставить `_ensure_pr` как есть и лишь усилить его логирование (created/existed/failed);
|
||||
функциональная цель ORCH-082 достигается врезкой Р-2 независимо.
|
||||
|
||||
### Р-5. Kill-switch и область действия
|
||||
|
||||
- `merge_verify_autocreate_pr_enabled: bool = True`
|
||||
(env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`) в `src/config.py`, рядом с
|
||||
`merge_verify_enabled`/`regression_guard_enabled`.
|
||||
- `False` → ровно прежнее поведение: авто-создания нет, «no open PR» → HOLD как в ORCH-074 (AC-8).
|
||||
- Область — `merge_gate.merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); прочие
|
||||
репо — no-op, создание PR остаётся за прежним механизмом (AC-9). Отдельного `*_repos` для
|
||||
авто-создания НЕ вводим: семантически оно неотделимо от merge-verify, у которого уже есть область.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- Закрыт структурный пробел: к merge-verify ветка гарантированно имеет открытый код-PR; ложный
|
||||
HOLD «no open PR» больше не требует ручного вмешательства (AC-2/AC-3).
|
||||
- Защита ORCH-073 цела и приоритетна: верификация остаётся **только** `verify_merged_to_main`
|
||||
(SHA-в-main) + `check_main_regression`. Реально невлитый код → HOLD как прежде (AC-4/FR-3).
|
||||
- Идемпотентность по факту Gitea (наличие открытого PR), без новой колонки/таблицы — согласуется с
|
||||
restart-safe-моделью merge-verify; повторный заход (reaper/reconciler/re-approve) → `existed`,
|
||||
дублей нет (FR-5/AC-2).
|
||||
- Распознаваемые исходы в логах и в HOLD-тексте: created / existed / failed (G3/AC-5).
|
||||
- Инварианты сохранены: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
|
||||
exit-коды хука, merge-gate, image-freshness — не тронуты (AC-10). `main` не push/force-push.
|
||||
|
||||
### Минусы / ограничения
|
||||
- Auto-создание PR на ребре `deploy → done` означает, что код-PR может появиться **после** того,
|
||||
как все гейты (security/merge-gate/staging/image-freshness) уже пройдены по ветке. Это безопасно
|
||||
по времени (BRD §6 допущение): ревью/гейты валидируют **код ветки**, а PR — лишь механизм
|
||||
слияния; merge-verify исполняется ПОСЛЕ всех гейтов. PR здесь не обходит ревью.
|
||||
- При недоступности Gitea задача попадёт в HOLD (как и сегодня) — но теперь с явным текстом
|
||||
«PR создать не удалось» вместо «PR не влит». Это сознательный fail-closed (AC-7): never-raise,
|
||||
честный HOLD, не ложно-зелёный `done`.
|
||||
- Небольшое дублирование Gitea-вызовов между `ensure_open_pr` и `merge_pr` (оба GET список PR). Это
|
||||
приемлемо: два независимых leaf-актора с одинаковым фильтром важнее микро-оптимизации; объединять
|
||||
в один вызов — увеличить связность без пользы.
|
||||
|
||||
### Влияние на self-hosting
|
||||
Изменение строго аддитивно и под kill-switch (`True`). Прод-контейнер не рестартится этой задачей;
|
||||
выкат — через staging-гейт (8501) как любая ORCH-задача. На ребре `deploy → done` риск-профиль не
|
||||
растёт: при любом сбое — HOLD, не падение `advance_stage`, конвейер всех проектов не встаёт.
|
||||
|
||||
## Связанные документы
|
||||
- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
|
||||
- Тех-риски: `10-tech-risks.md`
|
||||
- Глобальный амендмент: [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md)
|
||||
- Контекст merge-verify: [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md),
|
||||
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md)
|
||||
- Постмортем фантомного merge: `docs/history/LESSONS_2026-06-08_phantom-merge.md`,
|
||||
runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md`
|
||||
27
docs/work-items/ORCH-082/10-tech-risks.md
Normal file
27
docs/work-items/ORCH-082/10-tech-risks.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 10 — Технические риски: ORCH-082 (ORCH-81)
|
||||
|
||||
Риски точечной врезки «ensure_open_pr перед merge-verify». Все — в зоне ребра `deploy → done`
|
||||
(self-hosting), под kill-switch `merge_verify_autocreate_pr_enabled`.
|
||||
|
||||
| ID | Риск | Вероятн. | Влияние | Митигация |
|
||||
|----|------|----------|---------|-----------|
|
||||
| **R1** | `ensure_open_pr` выбирает/создаёт **не тот** PR (авто-docs-PR `base != main`) → `merge_pr` мержит/верифицирует не тот PR | Сред. | Высокое | Фильтр `head.ref==branch` И `base.ref=="main"`, **идентичный** `merge_pr` (ORCH-073 FR-3). Тест AC-6: ветка с docs-PR (`base!=main`) → актор его игнорирует и создаёт код-PR на `main`. |
|
||||
| **R2** | Создание PR **маскирует** реально невлитый код → ложно-зелёный `done` (регресс ORCH-073) | Низк. | Критич. | Верификация остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` НЕ влияет на вердикт merge. Регресс-тест AC-4: `verify_merged_to_main→False` ⇒ HOLD, не `done`. |
|
||||
| **R3** | Гонка: параллельно создаётся 2 PR → дубль | Низк. | Сред. | Идемпотентность FR-1.3: на ошибку «PR exists»/409/422 — повторный GET → `existed`; PR создаётся только если GET пуст. Тест AC-2. |
|
||||
| **R4** | Исключение из `ensure_open_pr` пробрасывается в `advance_stage` → падение перехода | Низк. | Высокое | Контракт never-raise (`except Exception → ("failed", reason)`); врезка обёрнута внешним try/except `_handle_merge_verify`. `failed` → структурированный HOLD, не throw. Тест AC-7. |
|
||||
| **R5** | Gitea недоступна на ребре `deploy → done` → задача в HOLD | Низк. | Сред. | Сознательный fail-closed: `failed` → честный HOLD+alert (`_hold_pr_create_failed`), НЕ ложный `done`. Текст HOLD отличим от not-merged (AC-5) — оператор видит причину. Reaper/reconciler/re-approve переиграют, когда Gitea вернётся (FR-5). |
|
||||
| **R6** | Оператор не различит HOLD «PR не создан» и HOLD «PR не влит» | Сред. | Низк. | Отдельный helper `_hold_pr_create_failed` с собственным текстом и `result.note="pr-create-failed-hold"` (≠ `merge-not-verified-hold`); лог-строка `ensure_open_pr -> failed: <reason>`. AC-5. |
|
||||
| **R7** | Расхождение логики выбора/создания PR между `launcher._ensure_pr` и `merge_gate.ensure_open_pr` | Сред. | Сред. | Рекомендованное делегирование `_ensure_pr → ensure_open_pr` (единый код). Если не делегируем — обе копии используют ОДИН фильтр `head==branch && base==main`; тест на согласованность. |
|
||||
| **R8** | Включение по умолчанию (`True`) меняет прод-поведение скрытно | Низк. | Сред. | Поведение строго аддитивно: при наличии PR → `existed`/no-op; меняется лишь ранее-падавший путь «no open PR». Kill-switch `False` → 1:1 ORCH-074 (AC-8). Выкат через staging-гейт (8501). |
|
||||
| **R9** | Регресс инвариантов (`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/exit-коды) | Низк. | Высокое | Под-гейт-врезка в `advance_stage`, НЕ новый `QG_CHECKS`-элемент и НЕ новая стадия; БД не трогается (идемпотентность из Gitea). Тест AC-10 + полный `pytest`. |
|
||||
|
||||
## Зоны без изменений (подтверждение границ)
|
||||
- **Инфраструктура/топология** — без изменений → `07-infra-requirements.md` не требуется.
|
||||
- **Схема БД** — без изменений (идемпотентность выводится из Gitea) → `08-data-requirements.md`
|
||||
не требуется.
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука,
|
||||
merge-gate (ORCH-043), image-freshness (ORCH-058), terminal-sync — не тронуты.
|
||||
|
||||
## Главный архитектурный приоритет
|
||||
При любом конфликте «создать PR» **проигрывает** «не дать ложно-зелёный `done`» (защита ORCH-073).
|
||||
Создание PR — страховка инварианта ДО merge_pr, никогда не подмена верификации merge.
|
||||
65
docs/work-items/ORCH-082/12-review.md
Normal file
65
docs/work-items/ORCH-082/12-review.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-082
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-082 — Гарантированный идемпотентный код-PR перед merge-verify
|
||||
|
||||
## Summary
|
||||
Изменение закрывает отсутствующий инвариант «к моменту merge-verify у ветки есть открытый
|
||||
код-PR» (root cause ложного HOLD «no open PR» на деплое ORCH-074). Реализовано строго аддитивно,
|
||||
по дизайну ADR-001: новый идемпотентный leaf-актор `merge_gate.ensure_open_pr`, точечная врезка в
|
||||
`stage_engine._handle_merge_verify` ПЕРЕД `merge_pr`, distinguishable HOLD-helper
|
||||
`_hold_pr_create_failed`, делегирование `launcher._ensure_pr` в единый актор, kill-switch
|
||||
`merge_verify_autocreate_pr_enabled`. Защита ORCH-073 (SHA-в-main + регресс-гард) не ослаблена и
|
||||
остаётся приоритетной. Машина стадий, `QG_CHECKS`, схема БД, контракты деплоя — не тронуты.
|
||||
|
||||
Все 4 оси проверки пройдены:
|
||||
- **ТЗ (02-trz.md):** FR-1..FR-5 реализованы — идемпотентный актор с фильтром
|
||||
`head==branch & base=="main"`, врезка после `validated_revision` и до `merge_pr`, честный HOLD
|
||||
на `failed`, защита ORCH-073 цела, идемпотентность повторного прохода.
|
||||
- **AC (03-acceptance-criteria.md):** AC-1..AC-11 покрыты. Root cause задокументирован в ADR
|
||||
(R-A структурный + R-C проксимальный для ORCH-074); идемпотентность/existed (TC-02, TC-05);
|
||||
autocreate до merge_pr (TC-06); защита ORCH-073 (TC-07); логи различают исход (TC-12); фильтр
|
||||
base==main (TC-03); never-raise (TC-04, TC-08); kill-switch off (TC-09); условность non-self
|
||||
(TC-10); инварианты + документация; pytest зелёный.
|
||||
- **ADR:** реализация 1:1 соответствует Р-1..Р-5 ADR-001; не нарушает глобальные adr-0013/0014
|
||||
(амендмент adr-0016 корректно зарегистрирован).
|
||||
- **Качество кода:** never-raise соблюдён (все внешние вызовы в try/except), docstrings на
|
||||
публичных функциях, тесты содержательные (мок Gitea HTTP + интеграционные на под-гейт, не
|
||||
тривиальные). Секреты не хардкодятся (token из settings). `main` не push/force-push.
|
||||
|
||||
`pytest tests/ -q` → **1046 passed**. Целевые наборы (`test_orch082_ensure_pr.py`,
|
||||
`test_orch082_merge_verify_autocreate.py`, `test_merge_verify.py`) — зелёные.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- [ ] Поведенческое уточнение `launcher._ensure_pr`: после делегирования в `ensure_open_pr`
|
||||
developer-путь теперь требует `base=="main"` (раньше принимался любой открытый PR с
|
||||
`head==branch`). Это корректное усиление (выравнивание с `merge_pr`) и для штатного потока
|
||||
PR всегда создаётся на `main` — регресса нет; зафиксировано для истории, действий не требует.
|
||||
|
||||
## Документация
|
||||
Документация обновлена в том же PR — соответствие правилу №2/№6 CLAUDE.md:
|
||||
- `docs/architecture/README.md` — добавлен раздел ORCH-082 в блок merge-verify (строки 209-240).
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`.
|
||||
- `.env.example` — `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true` + комментарий.
|
||||
- `docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md` — сквозной ADR (амендмент
|
||||
adr-0013/0014), зарегистрирован в `docs/architecture/adr/README.md` (макс. номер → 0016).
|
||||
- `docs/work-items/ORCH-082/06-adr/ADR-001-*.md` — детальный ADR (root cause + дизайн).
|
||||
- API сервиса не менялось (новых endpoint нет), конфиг-флаг отражён в `.env.example`. Все
|
||||
изменения `src/` (merge_gate, stage_engine, launcher, config) задокументированы.
|
||||
|
||||
**Вердикт: APPROVED** — P0/P1 отсутствуют, документация обновлена, тесты зелёные.
|
||||
81
docs/work-items/ORCH-082/13-test-report.md
Normal file
81
docs/work-items/ORCH-082/13-test-report.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-082
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-082
|
||||
|
||||
Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-082-orch-81-pr-merge-verify-hold
|
||||
- Дата: 2026-06-09
|
||||
- Review verdict: APPROVED (12-review.md, P0/P1 отсутствуют)
|
||||
|
||||
## Проверка окружения
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — прод-контейнер 8500 жив.
|
||||
- Тесты прогнаны в worktree ветки (прод не затронут, деструктивных операций нет).
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok"}` — OK |
|
||||
| `GET /status` | OK — ORCH-082 (id=61) виден на стадии `testing` |
|
||||
| `GET /queue` | OK — `running:1, queued:0`, breaker `closed`, reconcile/reaper/post_deploy активны |
|
||||
|
||||
## Результаты (привязка к 04-test-plan.yaml)
|
||||
|
||||
| TC ID | Тип | Описание | Тест | Результат |
|
||||
|-------|-----|----------|------|-----------|
|
||||
| TC-01 | unit | ensure_open_pr: PR нет → POST создаёт → ('created', N); фильтр base==main | test_tc01_creates_pr_when_absent | PASS |
|
||||
| TC-02 | unit | PR head==branch И base==main уже есть → ('existed', N), POST не вызывается | test_tc02_existed_no_duplicate | PASS |
|
||||
| TC-03 | unit | Мульти-PR: только docs-PR (base!=main) → создаётся PR на main (AC-6) | test_tc03_docs_pr_not_counted_creates_on_main | PASS |
|
||||
| TC-04 | unit | never-raise: GET/POST кидает ошибку → ('failed', reason), не всплывает (AC-7) | test_tc04_never_raise_on_get_error / _on_post_error / _failed_when_post_non_2xx | PASS (3) |
|
||||
| TC-05 | unit | Гонка: POST 'PR exists' (409/422) → повторный GET → ('existed', N), без дубля | test_tc05_race_post_conflict_confirms_existing[409,422] | PASS (2) |
|
||||
| TC-06 | integration | PR отсутствовал → ensure создаёт → merge_pr → verify True → done без HOLD (AC-3) | test_tc06_autocreate_then_merge_then_done | PASS |
|
||||
| TC-07 | integration | Регресс ORCH-073: verify=False → HOLD + set_issue_blocked, НЕ done, без отката (AC-4) | test_tc07_verify_false_still_holds | PASS |
|
||||
| TC-08 | integration | ensure → 'failed' (Gitea down) → честный HOLD+alert, текст ≠ 'not merged' (AC-7) | test_tc08_ensure_failed_holds_distinct | PASS |
|
||||
| TC-09 | integration | Kill-switch off → ensure не вызывается, 'no open PR' → прежний HOLD 1:1 (AC-8) | test_tc09_killswitch_off_no_autocreate | PASS |
|
||||
| TC-10 | integration | Условность: non-self репо (applies=False) → no-op, авто-создание не выполняется (AC-9) | test_tc10_non_self_repo_noop | PASS |
|
||||
| TC-11 | integration | Идемпотентный повторный проход: PR existed, already-merged → verify True → done (FR-5) | test_tc11_idempotent_redrive | PASS |
|
||||
| TC-12 | unit | Логи различают created/existed/failed; HOLD create-failed ≠ HOLD not-merged (AC-5) | test_tc12_logs_distinguish_outcomes | PASS |
|
||||
| TC-13 | integration | Happy-path ORCH-071/073 не изменён: verify True → done, merged_to_main: true | test_merge_verify.py (verify_true_when_sha_is_ancestor + 7 регресс-тестов) | PASS |
|
||||
|
||||
Все 13 TC из тест-плана покрыты и зелёные.
|
||||
|
||||
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
|
||||
- **AC-1** Root cause в ADR-001 (R-A структурный + R-C для ORCH-074) — подтверждено review.
|
||||
- **AC-2** Идемпотентный код-PR, без дублей — TC-02, TC-05, TC-11 — PASS.
|
||||
- **AC-3** Авто-создание PR ПЕРЕД merge_pr — TC-06 — PASS.
|
||||
- **AC-4** Защита ORCH-073 цела (verify=False → HOLD, не done) — TC-07 + test_merge_verify — PASS.
|
||||
- **AC-5** Логи различают исход PR — TC-12 — PASS.
|
||||
- **AC-6** Фильтр base==main (docs-PR не код-PR) — TC-03 — PASS.
|
||||
- **AC-7** Never-raise + честный HOLD при недоступности Gitea — TC-04, TC-08 — PASS.
|
||||
- **AC-8** Kill-switch off → поведение 1:1 — TC-09 — PASS.
|
||||
- **AC-9** Условность self-hosting — TC-10 — PASS.
|
||||
- **AC-10** Инварианты не нарушены, документация обновлена — подтверждено review (README/CHANGELOG/.env.example/ADR).
|
||||
- **AC-11** pytest зелёный — **1046 passed** — PASS.
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полный прогон:
|
||||
```
|
||||
1046 passed, 1 warning in 25.57s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в src/config.py:5, не относится к ORCH-082, предсуществующий.)
|
||||
|
||||
Целевые наборы:
|
||||
```
|
||||
tests/test_orch082_ensure_pr.py ............ (8 passed)
|
||||
tests/test_orch082_merge_verify_autocreate.py ....... (7 passed)
|
||||
tests/test_merge_verify.py ........ (8 passed)
|
||||
======================== 23 passed, 1 warning in 0.42s =========================
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS** — все 1046 тестов зелёные, целевые наборы ORCH-082 + регресс merge-verify зелёные,
|
||||
smoke API (health/status/queue) OK, все 13 TC и AC-1..AC-11 покрыты. Задача готова к переходу
|
||||
на стадию `deploy-staging`.
|
||||
49
docs/work-items/ORCH-082/15-staging-log.md
Normal file
49
docs/work-items/ORCH-082/15-staging-log.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T21:55:49Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live staging environment (`orchestrator-staging`, port 8501),
|
||||
run inside the container per the canonical method (ORCH-048, ADR-001):
|
||||
|
||||
```
|
||||
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)
|
||||
|
||||
- REAL failed: none
|
||||
- SANDBOX_INFRA failed (waived per ORCH-061): C9a, C9b
|
||||
|
||||
All REAL pipeline checks (Block A SMOKE, Block B ACCESS, C7/C8) passed. The only failures are the
|
||||
two infra-only sandbox checks (C9a branch-in-sandbox / C9b analyst-job-enqueued), which depend on
|
||||
SANDBOX bot accounts being members of the sandbox project — not on the pipeline. Tolerance is enabled
|
||||
(`staging_infra_tolerance_enabled=True`), so these are waived and the script exits 0 (fail-closed for
|
||||
any REAL failure remains intact).
|
||||
|
||||
```
|
||||
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 …', 'C9b …'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
### Block-by-block summary
|
||||
|
||||
| Block | Check | Result |
|
||||
|-------|-------|--------|
|
||||
| A | A1 GET /health → 200 status=ok | ✓ PASS |
|
||||
| A | A2 GET /queue → 200 with counts/max_concurrency/resilience | ✓ PASS |
|
||||
| A | A3 ORCH_STAGING=true (not prod) | ✓ PASS |
|
||||
| B | B4 Plane: sandbox project accessible | ✓ PASS |
|
||||
| B | B5 Gitea: orchestrator-sandbox accessible, push=true | ✓ PASS |
|
||||
| B | B6 Registry: sandbox present, prod ET/ORCH absent | ✓ PASS |
|
||||
| C | C7 Create issue in Plane SANDBOX | ✓ PASS |
|
||||
| C | C8 Trigger pipeline via /webhook/plane | ✓ PASS |
|
||||
| C | C9a Branch appears in orchestrator-sandbox | ✗ FAIL (sandbox-infra, waived) |
|
||||
| C | C9b Analyst job enqueued in staging queue | ✗ FAIL (sandbox-infra, waived) |
|
||||
|
||||
Cleanup completed: test Plane issue deleted (HTTP 204); no branch created to delete.
|
||||
@@ -158,12 +158,50 @@ def resolve_agent_model(agent: str, project_id: str = None) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _agent_effort_floor(agent: str) -> str:
|
||||
"""ORCH-081 (ORCH-52h): per-role non-empty floor for --effort resolution.
|
||||
|
||||
Returns the DECLARED class-default of the ``agent_effort_<agent>`` field on
|
||||
Settings (e.g. developer -> ``xhigh``, tester/deployer -> ``medium``, the rest
|
||||
-> ``high``). This is the value pydantic WOULD have used were it not clobbered
|
||||
by a spurious empty env var (``ORCH_AGENT_EFFORT_<ROLE>=``): the class-default
|
||||
is fixed in the class body and a present-but-empty env value cannot override it,
|
||||
so it is a robust floor even when the host ``.env`` zeroes every effort var.
|
||||
|
||||
config.py is the single source of truth: upgrading developer to ``xhigh`` there
|
||||
automatically raises the floor here — no second map to keep in sync (ADR-001).
|
||||
|
||||
Unknown agent (a name outside the 6 roles) has no ``agent_effort_<agent>``
|
||||
field; we degrade to the class-default of ``agent_effort_default`` (``high``),
|
||||
a safe non-empty floor. Never raises.
|
||||
"""
|
||||
fields = type(settings).model_fields
|
||||
for key in (f"agent_effort_{agent}", "agent_effort_default"):
|
||||
field = fields.get(key)
|
||||
if field is not None and field.default:
|
||||
return field.default
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_agent_effort(agent: str, project_id: str = None) -> str:
|
||||
"""ORCH-41: resolve the --effort level for an agent (optionally per-project).
|
||||
|
||||
Same priority as resolve_agent_model. The resolved value is validated against
|
||||
VALID_EFFORTS; an invalid value is logged and dropped (returns "") so a typo
|
||||
in env/projects_json can never pass a bad flag to the CLI.
|
||||
Same priority as resolve_agent_model, with one extra level below the global
|
||||
default (ORCH-081 / ADR-001):
|
||||
1. project-override (projects_json.agent_efforts[agent])
|
||||
2. per-agent env (settings.agent_effort_<agent>)
|
||||
3. global default (settings.agent_effort_default)
|
||||
4. per-role FLOOR (class-default of agent_effort_<agent>) — NEW
|
||||
|
||||
The floor only kicks in when levels 1-3 are all empty (the prod bug: a present
|
||||
but empty ``ORCH_AGENT_EFFORT_*=`` clobbers every default to ''), guaranteeing
|
||||
a non-empty target effort for the 6 known roles regardless of host .env state.
|
||||
|
||||
The floor is applied BEFORE validation and ONLY to an empty resolve, so it
|
||||
never masks a typo: an explicit invalid value (e.g. ``turbo``) is non-empty,
|
||||
skips the floor, and is logged + dropped to "" exactly as in ORCH-41 (the
|
||||
resolved value is validated against VALID_EFFORTS; an invalid value can never
|
||||
pass a bad flag to the CLI). Never raises.
|
||||
"""
|
||||
value = _resolve_agent_attr(
|
||||
agent, project_id,
|
||||
@@ -171,6 +209,11 @@ def resolve_agent_effort(agent: str, project_id: str = None) -> str:
|
||||
env_attr_prefix="agent_effort_",
|
||||
default_attr="agent_effort_default",
|
||||
)
|
||||
if not value:
|
||||
# Levels 1-3 all empty (typically a prod .env with empty ORCH_AGENT_EFFORT_*):
|
||||
# fall through to the per-role floor (class-default). Applied before
|
||||
# validation but only here, so a typo (non-empty) never reaches this branch.
|
||||
value = _agent_effort_floor(agent)
|
||||
if value and value not in VALID_EFFORTS:
|
||||
logger.warning(
|
||||
f"Invalid effort '{value}' for agent '{agent}' "
|
||||
@@ -1034,35 +1077,28 @@ class AgentLauncher:
|
||||
return None
|
||||
|
||||
def _ensure_pr(self, repo: str, branch: str, run_id: int):
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
base_url = f"{settings.gitea_url}/api/v1"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/repos/{owner}/{repo}/pulls",
|
||||
params={"state": "open", "head": branch},
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
prs = resp.json()
|
||||
if prs:
|
||||
return prs[0]["number"]
|
||||
parts = branch.split("/")
|
||||
title = parts[-1] if parts else branch
|
||||
resp = httpx.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/pulls",
|
||||
json={"title": f"feat: {title}", "head": branch, "base": "main",
|
||||
"body": f"Auto-created by orchestrator after developer run_id={run_id}"},
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
pr_number = resp.json()["number"]
|
||||
logger.info(f"Created PR #{pr_number} for {branch}")
|
||||
return pr_number
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PR for {branch}: {e}")
|
||||
return None
|
||||
"""Ensure an open code-PR exists for ``branch``; return its number or None.
|
||||
|
||||
ORCH-082 (ADR-001 Р-4): delegated to the single idempotent PR-creation actor
|
||||
``merge_gate.ensure_open_pr`` so PR creation lives in ONE place and logs the
|
||||
same created/existed/failed outcomes (G3). The CALL TRIGGER is unchanged — the
|
||||
caller (`_monitor_agent`) still invokes this ONLY on the developer path with a
|
||||
fresh worktree commit; only the implementation under the hood is shared. The
|
||||
actor uses the same ``head==branch AND base==main`` filter as ``merge_pr``, so
|
||||
the developer-created PR and the one merge-verify merges are guaranteed to be
|
||||
the same code-PR. Never raises (the actor is never-raise); ``failed`` -> None,
|
||||
preserving the previous "best-effort, return None on failure" contract.
|
||||
"""
|
||||
from .. import merge_gate
|
||||
status, detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(f"_ensure_pr({branch}, run_id={run_id}) -> {status} ({detail})")
|
||||
if status in ("created", "existed"):
|
||||
try:
|
||||
return int(detail)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
logger.error(f"Failed to ensure PR for {branch}: {detail}")
|
||||
return None
|
||||
|
||||
def _write_task_file(self, repo: str, branch: str, task_file: str, content: str):
|
||||
"""Write task file directly into the task's worktree.
|
||||
|
||||
@@ -97,13 +97,15 @@ class Settings(BaseSettings):
|
||||
agent_model_deployer: str = ""
|
||||
|
||||
# ORCH-41: per-agent effort / reasoning level: low|medium|high|xhigh|max.
|
||||
# Empty -> agent_effort_default. Same resolution order as model. Default split:
|
||||
# thinking agents (analyst/architect/developer/reviewer) -> high; mechanical
|
||||
# agents (tester/deployer) -> medium.
|
||||
# Empty -> agent_effort_default. Same resolution order as model. Default split
|
||||
# (ORCH-081/ORCH-52h): thinking agents (analyst/architect/reviewer) -> high;
|
||||
# developer -> xhigh (coding/agentic role, Opus 4.8 canon); mechanical agents
|
||||
# (tester/deployer) -> medium. These class-defaults are ALSO the per-role floor
|
||||
# used by resolve_agent_effort when the env is empty (single source of truth).
|
||||
agent_effort_default: str = "high"
|
||||
agent_effort_analyst: str = "high"
|
||||
agent_effort_architect: str = "high"
|
||||
agent_effort_developer: str = "high"
|
||||
agent_effort_developer: str = "xhigh"
|
||||
agent_effort_reviewer: str = "high"
|
||||
agent_effort_tester: str = "medium"
|
||||
agent_effort_deployer: str = "medium"
|
||||
@@ -440,6 +442,22 @@ class Settings(BaseSettings):
|
||||
# merge_verify_repos), so non-self repos are a no-op.
|
||||
regression_guard_enabled: bool = True
|
||||
|
||||
# ORCH-082 (ADR-001 Р-5): guarantee an open code-PR BEFORE the deterministic
|
||||
# merge_pr inside the merge-verify under-gate. The pipeline never guaranteed the
|
||||
# branch had an open PR (head==branch, base==main) at merge time — PRs are created
|
||||
# ONLY on the developer path with a fresh worktree commit (launcher._ensure_pr),
|
||||
# so a branch (e.g. after a manual main restore / a bounce with no new commits)
|
||||
# could reach merge-verify PR-less -> merge_pr returns "no open PR" -> a FALSE HOLD
|
||||
# that ORCH-073 fail-closed correctly catches but should never have to. The
|
||||
# idempotent leaf-actor merge_gate.ensure_open_pr creates/finds the code-PR ДО
|
||||
# merge_pr; ORCH-073's SHA-in-main proof is untouched and stays authoritative.
|
||||
# merge_verify_autocreate_pr_enabled -> kill-switch (env
|
||||
# ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED). False -> exactly the pre-ORCH-082
|
||||
# behaviour (no auto-create; "no open PR" -> HOLD as before). Reuses the
|
||||
# merge_verify_applies scope (self-hosting / merge_verify_repos) — no separate
|
||||
# *_repos, since auto-create is semantically inseparable from merge-verify.
|
||||
merge_verify_autocreate_pr_enabled: bool = True
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
@@ -587,6 +587,101 @@ def merge_verify_applies(repo: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
|
||||
"""Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists.
|
||||
|
||||
ORCH-082 (ADR-001 Р-1 / FR-1): the idempotent leaf-actor that closes the missing
|
||||
invariant "by merge-verify time the branch has an open code-PR". The pipeline used
|
||||
to create a PR ONLY on the developer path with a fresh worktree commit
|
||||
(``launcher._ensure_pr``), so a branch could reach the ``deploy -> done`` merge-verify
|
||||
under-gate with no open code-PR -> ``merge_pr`` returned ``"no open PR"`` -> a FALSE
|
||||
HOLD (the ORCH-074 incident). This actor creates/finds the code-PR ДО the
|
||||
deterministic ``merge_pr``; ORCH-073's SHA-in-main proof stays authoritative.
|
||||
|
||||
Algorithm (FR-1):
|
||||
1. ``GET …/pulls?state=open`` -> a PR with **``head.ref==branch`` AND
|
||||
``base.ref=="main"``**. The filter is **identical** to ``merge_pr``/ORCH-073
|
||||
FR-3 so both actors agree on exactly the same PR — an auto docs-PR
|
||||
(``base != main``) is NOT a code-PR (AC-6). Found -> ``("existed", "<number>")``.
|
||||
2. Otherwise ``POST …/pulls`` (``head=branch``, ``base=main``, auto title/body) ->
|
||||
``201`` -> ``("created", "<number>")``.
|
||||
3. Idempotency on a race: a ``POST`` that fails because the PR already exists
|
||||
(Gitea ``409``/``422``) -> a repeat ``GET`` (step 1) confirms the existing PR ->
|
||||
``("existed", …)``; no duplicate is created (AC-2 / FR-5).
|
||||
4. Any other HTTP/parse/network error -> ``("failed", "<reason>")``.
|
||||
|
||||
Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``).
|
||||
Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is
|
||||
NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
base = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}"
|
||||
timeout = settings.merge_pr_timeout_s
|
||||
|
||||
def _find_open_code_pr() -> int | None:
|
||||
"""GET open PRs; return the code-PR number (head==branch AND base==main)."""
|
||||
resp = httpx.get(
|
||||
f"{base}/pulls", params={"state": "open"}, headers=headers, timeout=timeout
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
for pr in resp.json() or []:
|
||||
if (
|
||||
pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
return pr.get("number")
|
||||
return None
|
||||
|
||||
# Step 1: an open code-PR already exists -> existed (no duplicate POST).
|
||||
existing = _find_open_code_pr()
|
||||
if existing is not None:
|
||||
logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing)
|
||||
return "existed", str(existing)
|
||||
|
||||
# Step 2: create the code-PR onto main.
|
||||
parts = branch.split("/")
|
||||
title = parts[-1] if parts else branch
|
||||
m = httpx.post(
|
||||
f"{base}/pulls",
|
||||
json={
|
||||
"title": f"feat: {title}",
|
||||
"head": branch,
|
||||
"base": "main",
|
||||
"body": f"Auto-created by orchestrator merge-verify for {branch}",
|
||||
},
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if m.status_code in (200, 201):
|
||||
number = (m.json() or {}).get("number")
|
||||
logger.info("ensure_open_pr: created PR #%s for %s/%s", number, repo, branch)
|
||||
return "created", str(number)
|
||||
|
||||
# Step 3: race / already-exists (409 conflict, 422 unprocessable) -> re-GET.
|
||||
if m.status_code in (409, 422):
|
||||
again = _find_open_code_pr()
|
||||
if again is not None:
|
||||
logger.info(
|
||||
"ensure_open_pr: %s/%s PR already existed on retry (#%s, HTTP %s)",
|
||||
repo, branch, again, m.status_code,
|
||||
)
|
||||
return "existed", str(again)
|
||||
|
||||
detail = (m.text or "").strip()[:200]
|
||||
logger.warning(
|
||||
"ensure_open_pr: create failed for %s/%s: HTTP %s %s",
|
||||
repo, branch, m.status_code, detail,
|
||||
)
|
||||
return "failed", f"create PR failed: HTTP {m.status_code}"
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract (AC-7)
|
||||
logger.warning("ensure_open_pr unexpected error for %s/%s: %s", repo, branch, e)
|
||||
return "failed", f"ensure_open_pr error: {e}"
|
||||
|
||||
|
||||
def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
"""Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API.
|
||||
|
||||
@@ -730,6 +825,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
|
||||
("ORCH-069", "qg0_title_max", "src/config.py"),
|
||||
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
|
||||
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
|
||||
("ORCH-082", "ensure_open_pr", "src/merge_gate.py"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1321,6 +1321,52 @@ def _hold_main_regressed(
|
||||
return True
|
||||
|
||||
|
||||
def _hold_pr_create_failed(
|
||||
task_id, repo, work_item_id, branch, reason: str, result: AdvanceResult
|
||||
) -> bool:
|
||||
"""HOLD the task because the open code-PR could not be ensured (ORCH-082 Р-3).
|
||||
|
||||
FR-2/FR-4 (AC-5/AC-7): ``ensure_open_pr`` returned ``"failed"`` (Gitea unreachable /
|
||||
HTTP error) — there is no open code-PR and one could not be created. Symmetric to the
|
||||
not-merged / regressed HOLD: task stays on ``deploy`` (NOT done), NO rollback to
|
||||
development, ALERT-only (Telegram + Plane ``set_issue_blocked`` + comment). The HOLD
|
||||
text MUST be distinguishable from the not-merged HOLD so the operator sees the cause is
|
||||
"could not CREATE the PR" (infra), not "could not MERGE an existing one". Returns
|
||||
``True`` (INTERVENED). Never breaks the HOLD on a notify error; ``failed`` is a
|
||||
structured outcome, not a propagated exception (INV-1).
|
||||
"""
|
||||
merge_gate.note_not_merged_alert(work_item_id) # reuse the counter-notifier.
|
||||
msg = (
|
||||
f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, "
|
||||
f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача "
|
||||
f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR."
|
||||
)
|
||||
logger.warning(f"Task {task_id}: {msg}")
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_blocked(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: set_issue_blocked failed: {e}")
|
||||
try:
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
"\U0001f6a8 PR создать не удалось: " + reason + ". Открытый код-PR "
|
||||
"отсутствует — задача удержана на `deploy` (НЕ done). Проверьте "
|
||||
"доступность Gitea / создайте PR вручную и повторите approve.",
|
||||
author="deployer",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: plane pr-create-failed comment failed: {e}")
|
||||
try:
|
||||
send_telegram(f"\U0001f6a8 {msg}")
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: pr-create-failed telegram failed: {e}")
|
||||
result.alerted = True
|
||||
result.note = "pr-create-failed-hold"
|
||||
result.advanced = False
|
||||
return True
|
||||
|
||||
|
||||
def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceResult) -> bool:
|
||||
"""ORCH-071 merge-verify under-gate on the `deploy -> done` edge.
|
||||
|
||||
@@ -1353,6 +1399,24 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
|
||||
from . import image_freshness
|
||||
sha = image_freshness.validated_revision(repo, branch)
|
||||
|
||||
# ORCH-082 (Р-2 / FR-2): guarantee an open code-PR (head==branch, base==main)
|
||||
# BEFORE the deterministic merge_pr. The pipeline never guaranteed the branch
|
||||
# had one at merge time (PRs are created only on the developer path with a fresh
|
||||
# commit) -> a PR-less branch hit merge_pr "no open PR" -> a FALSE HOLD (ORCH-074).
|
||||
# `created`/`existed` -> proceed unchanged; `failed` -> honest HOLD with a
|
||||
# distinguishable text (NOT the not-merged HOLD). ORCH-073's SHA-in-main proof
|
||||
# below is untouched and stays authoritative. Kill-switch off -> 1:1 prior path.
|
||||
if settings.merge_verify_autocreate_pr_enabled:
|
||||
pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(
|
||||
f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})"
|
||||
)
|
||||
if pr_status == "failed":
|
||||
return _hold_pr_create_failed(
|
||||
task_id, repo, work_item_id, branch, pr_detail, result
|
||||
)
|
||||
# "created" | "existed" -> proceed normally to merge_pr.
|
||||
|
||||
# Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9).
|
||||
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
|
||||
logger.info(
|
||||
|
||||
@@ -98,4 +98,10 @@ def _disable_merge_verify(monkeypatch):
|
||||
# _handle_merge_verify's confirmed branch. Default it OFF too so unrelated
|
||||
# deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it.
|
||||
monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False)
|
||||
# ORCH-082: the merge-verify ensure_open_pr врезка makes REAL Gitea calls before
|
||||
# merge_pr. Default it OFF so unrelated deploy->done / merge-verify tests stay 1:1
|
||||
# (no network); the dedicated ORCH-082 tests re-enable it via their own monkeypatch.
|
||||
monkeypatch.setattr(
|
||||
_cfg.settings, "merge_verify_autocreate_pr_enabled", False, raising=False
|
||||
)
|
||||
yield
|
||||
|
||||
163
tests/test_orch082_ensure_pr.py
Normal file
163
tests/test_orch082_ensure_pr.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""ORCH-082 FR-1 — merge_gate.ensure_open_pr: idempotent open-code-PR actor.
|
||||
|
||||
Covers TC-01..05 / AC-2 / AC-6 / AC-7. The actor guarantees an open code-PR
|
||||
(``head==branch`` AND ``base=="main"``) exists before the deterministic ``merge_pr``,
|
||||
without ever creating a duplicate. Gitea HTTP is mocked; the actor honours the strict
|
||||
never-raise contract (any error -> ``("failed", reason)``).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
REPO = "orchestrator"
|
||||
BRANCH = "feature/ORCH-082-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
"""Minimal httpx.Response stand-in (status_code + json/text)."""
|
||||
|
||||
def __init__(self, status_code, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
|
||||
|
||||
def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None):
|
||||
"""Patch merge_gate's lazily-imported httpx with stub get/post callables."""
|
||||
import httpx
|
||||
|
||||
def fake_get(url, *a, **k):
|
||||
if record is not None:
|
||||
record.append(("GET", url, k.get("params")))
|
||||
return get_resp() if callable(get_resp) else get_resp
|
||||
|
||||
def fake_post(url, *a, **k):
|
||||
if record is not None:
|
||||
record.append(("POST", url, k.get("json")))
|
||||
if post_resp is None:
|
||||
raise AssertionError("POST must NOT be called")
|
||||
return post_resp() if callable(post_resp) else post_resp
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01: no open code-PR -> POST creates one -> ("created", N); base==main filter.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_creates_pr_when_absent(monkeypatch):
|
||||
record = []
|
||||
_install_httpx(
|
||||
monkeypatch,
|
||||
get_resp=_Resp(200, []), # no open PRs at all
|
||||
post_resp=_Resp(201, {"number": 42}),
|
||||
record=record,
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("created", "42")
|
||||
# POST body targets head=branch, base=main.
|
||||
post = [r for r in record if r[0] == "POST"][0]
|
||||
assert post[2]["head"] == BRANCH
|
||||
assert post[2]["base"] == "main"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: an open code-PR (head==branch AND base==main) already exists -> existed,
|
||||
# POST is never called (no duplicate).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_existed_no_duplicate(monkeypatch):
|
||||
payload = [{"number": 7, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]
|
||||
_install_httpx(monkeypatch, get_resp=_Resp(200, payload), post_resp=None)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("existed", "7") # POST stub would raise if called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 (AC-6): only a docs-PR (base != main) exists -> NOT a code-PR -> create on main.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_docs_pr_not_counted_creates_on_main(monkeypatch):
|
||||
record = []
|
||||
# An open PR exists but onto a docs base, and another onto a different head.
|
||||
docs_payload = [
|
||||
{"number": 9, "head": {"ref": BRANCH}, "base": {"ref": "docs/logs"}},
|
||||
{"number": 10, "head": {"ref": "other/branch"}, "base": {"ref": "main"}},
|
||||
]
|
||||
_install_httpx(
|
||||
monkeypatch,
|
||||
get_resp=_Resp(200, docs_payload),
|
||||
post_resp=_Resp(201, {"number": 11}),
|
||||
record=record,
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("created", "11")
|
||||
assert any(r[0] == "POST" for r in record)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (AC-7): Gitea GET/POST raise -> ("failed", reason), never raises.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_never_raise_on_get_error(monkeypatch):
|
||||
import httpx
|
||||
|
||||
def boom(*a, **k):
|
||||
raise httpx.ConnectError("gitea down")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
monkeypatch.setattr(httpx, "post", boom)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert status == "failed"
|
||||
assert detail # carries a reason
|
||||
|
||||
|
||||
def test_tc04_never_raise_on_post_error(monkeypatch):
|
||||
import httpx
|
||||
|
||||
def boom_post(*a, **k):
|
||||
raise httpx.ConnectError("post exploded")
|
||||
|
||||
_install_httpx(monkeypatch, get_resp=_Resp(200, []), post_resp=None)
|
||||
monkeypatch.setattr(httpx, "post", boom_post)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert status == "failed"
|
||||
|
||||
|
||||
def test_tc04_failed_when_post_non_2xx(monkeypatch):
|
||||
# A plain non-2xx, non-conflict POST -> failed (not silently swallowed).
|
||||
_install_httpx(
|
||||
monkeypatch, get_resp=_Resp(200, []), post_resp=_Resp(500, text="boom")
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert status == "failed"
|
||||
assert "500" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 (AC-2 / FR-5): race -> POST returns 409/422 "PR exists" -> re-GET confirms
|
||||
# the existing PR -> ("existed", N), no duplicate.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("conflict_code", [409, 422])
|
||||
def test_tc05_race_post_conflict_confirms_existing(monkeypatch, conflict_code):
|
||||
# First GET: no PR (so we attempt POST). POST: conflict. Re-GET: PR now present.
|
||||
gets = iter([
|
||||
_Resp(200, []), # first probe: absent
|
||||
_Resp(200, [{"number": 99, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]),
|
||||
])
|
||||
_install_httpx(
|
||||
monkeypatch,
|
||||
get_resp=lambda: next(gets),
|
||||
post_resp=_Resp(conflict_code, text="pull request already exists"),
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("existed", "99")
|
||||
183
tests/test_orch082_merge_verify_autocreate.py
Normal file
183
tests/test_orch082_merge_verify_autocreate.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""ORCH-082 FR-2/FR-3/FR-4 — ensure_open_pr врезка in _handle_merge_verify.
|
||||
|
||||
Covers TC-06..12 / AC-3 / AC-4 / AC-5 / AC-7 / AC-8 / AC-9 / FR-5. Calls the
|
||||
``deploy -> done`` under-gate handler directly with mocked merge_gate primitives +
|
||||
side effects (Plane/Telegram). Asserts the return contract: ``False`` == advance to
|
||||
``done``, ``True`` == HOLD (alert, NOT done). The ORCH-073 SHA-in-main proof stays
|
||||
authoritative — auto-creating a PR must NEVER mask un-merged code.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch082.db"))
|
||||
|
||||
import logging # noqa: E402
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-082"
|
||||
BRANCH = "feature/ORCH-082-x"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _wire(monkeypatch):
|
||||
# Under-gate in scope; autocreate ON; regression guard OFF (its own tests cover it).
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
# Silence Plane/Telegram side effects (assert on .called where relevant).
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 (AC-3): PR absent -> ensure_open_pr creates -> merge_pr -> verify True ->
|
||||
# deploy->done with NO false HOLD.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_autocreate_then_merge_then_done(monkeypatch):
|
||||
ensure = MagicMock(return_value=("created", "5"))
|
||||
merge = MagicMock(return_value=(True, "merged PR #5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert res.alerted is False
|
||||
ensure.assert_called_once_with(REPO, BRANCH)
|
||||
assert merge.called
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 (AC-4 / FR-3): PR created/merged but verify_merged_to_main=False (code not
|
||||
# in main) -> HOLD + set_issue_blocked, NOT done, no rollback. ORCH-073 protection
|
||||
# is untouched by auto-create.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_verify_false_still_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.note == "merge-not-verified-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 (AC-7 / AC-5): ensure_open_pr -> failed -> honest HOLD with distinguishable
|
||||
# text/note; merge_pr is NOT reached; advance_stage does not raise.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_ensure_failed_holds_distinct(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down")
|
||||
)
|
||||
merge = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.note == "pr-create-failed-hold" # distinct from "merge-not-verified-hold"
|
||||
assert not merge.called # merge_pr never reached
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (AC-8): kill-switch OFF -> ensure_open_pr NOT called; "no open PR" -> prior
|
||||
# HOLD 1:1 (ORCH-074 behaviour reproduced).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_killswitch_off_no_autocreate(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", False)
|
||||
ensure = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
# merge_pr finds no open PR -> verify False -> prior not-merged HOLD.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (False, "no open PR"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True
|
||||
assert res.note == "merge-not-verified-hold" # exactly the prior HOLD
|
||||
assert not ensure.called # auto-create skipped entirely
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 (AC-9): non-self repo (merge_verify_applies=False) -> врезка no-op, neither
|
||||
# ensure_open_pr nor merge_pr called.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_non_self_repo_noop(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
ensure = MagicMock()
|
||||
merge = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, "enduro-trails", "ET-1", "feature/x", res)
|
||||
|
||||
assert intervened is False # advance unchanged
|
||||
assert not ensure.called
|
||||
assert not merge.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-2 / FR-5): idempotent re-drive (reaper/reconciler) -> ensure existed,
|
||||
# merge_pr already-merged -> verify True -> done, no duplicate PR.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_idempotent_redrive(monkeypatch):
|
||||
ensure = MagicMock(return_value=("existed", "5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "already-merged"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert ensure.return_value[0] == "existed"
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 (AC-5): logs distinguish created/existed/failed; the create-failed HOLD text
|
||||
# differs from the not-merged HOLD text.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc12_logs_distinguish_outcomes(monkeypatch, caplog):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="orchestrator"):
|
||||
_handle_merge_verify(1, REPO, WI, BRANCH, AdvanceResult())
|
||||
assert any("ensure_open_pr -> created" in r.message for r in caplog.records)
|
||||
|
||||
# create-failed note differs from not-merged note (text-distinguishable HOLD).
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down")
|
||||
)
|
||||
res = AdvanceResult()
|
||||
_handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
assert res.note == "pr-create-failed-hold"
|
||||
assert res.note != "merge-not-verified-hold"
|
||||
@@ -26,13 +26,22 @@ from src.projects import ProjectConfig, reload_projects
|
||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
|
||||
|
||||
# ORCH-081/ORCH-52h: canonical effort per role (developer upgraded high -> xhigh).
|
||||
CANON_EFFORT = {
|
||||
"analyst": "high",
|
||||
"architect": "high",
|
||||
"developer": "xhigh",
|
||||
"reviewer": "high",
|
||||
"tester": "medium",
|
||||
"deployer": "medium",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "high")
|
||||
for a in ("analyst", "architect", "developer", "reviewer"):
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "high")
|
||||
for a in ("tester", "deployer"):
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "medium")
|
||||
for a, e in CANON_EFFORT.items():
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", e)
|
||||
monkeypatch.setattr(P.settings, "projects_json", "")
|
||||
reload_projects()
|
||||
yield
|
||||
@@ -50,19 +59,40 @@ def _install_registry(monkeypatch, agent_efforts):
|
||||
monkeypatch.setattr(P, "_BY_REPO", {p.repo: p for p in reg})
|
||||
|
||||
|
||||
# ---- default split ----------------------------------------------------------
|
||||
# ---- TC-01: canonical defaults (AC-1 / FR-4) --------------------------------
|
||||
def test_default_split():
|
||||
assert resolve_agent_effort("developer") == "high"
|
||||
assert resolve_agent_effort("developer") == "xhigh"
|
||||
assert resolve_agent_effort("architect") == "high"
|
||||
assert resolve_agent_effort("tester") == "medium"
|
||||
assert resolve_agent_effort("deployer") == "medium"
|
||||
|
||||
|
||||
# ---- level 4: nothing -> "" -------------------------------------------------
|
||||
def test_no_config_returns_empty(monkeypatch):
|
||||
@pytest.mark.parametrize("agent,expected", list(CANON_EFFORT.items()))
|
||||
def test_canonical_effort_all_roles(agent, expected):
|
||||
assert resolve_agent_effort(agent) == expected
|
||||
|
||||
|
||||
# ---- TC-02: empty env -> per-role floor (variant c, AC-2) -------------------
|
||||
@pytest.mark.parametrize("agent,expected", list(CANON_EFFORT.items()))
|
||||
def test_empty_env_falls_back_to_per_role_floor(monkeypatch, agent, expected):
|
||||
"""Models the prod bug: ORCH_AGENT_EFFORT_*= present-but-empty -> every level
|
||||
resolves to '' on the instance; the per-role floor (config class-default) must
|
||||
still yield the canonical level (NOT '')."""
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
for a in CANON_EFFORT:
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "")
|
||||
result = resolve_agent_effort(agent)
|
||||
assert result == expected
|
||||
assert result != ""
|
||||
|
||||
|
||||
# ---- unknown agent floor degrades to default (high), never '' ---------------
|
||||
def test_empty_env_unknown_agent_floor_is_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_tester", "")
|
||||
assert resolve_agent_effort("tester") == ""
|
||||
# An agent with no agent_effort_<name> field falls back to the
|
||||
# agent_effort_default class-default (high), a safe non-empty floor.
|
||||
assert resolve_agent_effort("nonexistent_role") == "high"
|
||||
|
||||
|
||||
# ---- level 2: per-agent env beats default -----------------------------------
|
||||
@@ -103,6 +133,45 @@ def test_all_valid_efforts_pass(monkeypatch):
|
||||
assert resolve_agent_effort("developer") == e
|
||||
|
||||
|
||||
# ---- TC-03: floor does NOT mask a typo (FR-3 / AC-5) ------------------------
|
||||
def test_floor_does_not_mask_typo(monkeypatch):
|
||||
"""An explicit invalid value is non-empty, so the floor is NOT applied: the
|
||||
value is validated and dropped to '' (never-break ORCH-41), even though the
|
||||
developer floor (xhigh) exists."""
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "turbo")
|
||||
assert resolve_agent_effort("developer") == ""
|
||||
|
||||
|
||||
# ---- TC-04: priority preserved — explicit config beats floor (FR-2) ---------
|
||||
def test_explicit_env_beats_floor(monkeypatch):
|
||||
"""Operator may deliberately downgrade developer to high; the explicit
|
||||
non-empty env wins over the xhigh floor."""
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "high")
|
||||
assert resolve_agent_effort("developer") == "high"
|
||||
|
||||
|
||||
def test_default_beats_floor(monkeypatch):
|
||||
"""A non-empty global default wins over the per-role floor (floor is strictly
|
||||
below default): default=max with empty per-agent -> max, not the xhigh floor."""
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "max")
|
||||
assert resolve_agent_effort("developer") == "max"
|
||||
|
||||
|
||||
def test_project_override_beats_floor(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
_install_registry(monkeypatch, {"developer": "high"})
|
||||
assert resolve_agent_effort("developer", ORCH_PLANE_ID) == "high"
|
||||
|
||||
|
||||
# ---- TC-05: xhigh is a valid effort (FR-5) ----------------------------------
|
||||
def test_xhigh_is_valid():
|
||||
assert "xhigh" in VALID_EFFORTS
|
||||
# developer canonical xhigh resolves (is not dropped by validation)
|
||||
assert resolve_agent_effort("developer") == "xhigh"
|
||||
|
||||
|
||||
# ---- flag assembly (mirror of launcher cmd construction) --------------------
|
||||
def _build_flags(model, effort, fb):
|
||||
model_flag = f"--model {model} " if model else ""
|
||||
@@ -111,6 +180,7 @@ def _build_flags(model, effort, fb):
|
||||
return f"{model_flag}{effort_flag}{fb_flag}"
|
||||
|
||||
|
||||
# ---- TC-06: flag assembly (AC-3) --------------------------------------------
|
||||
def test_flags_present_when_configured(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_fallback_model", "claude-sonnet-4-6")
|
||||
model = resolve_agent_model("developer")
|
||||
@@ -118,21 +188,32 @@ def test_flags_present_when_configured(monkeypatch):
|
||||
fb = settings.agent_fallback_model
|
||||
flags = _build_flags(model, effort, fb)
|
||||
assert "--model claude-opus-4-8 " in flags
|
||||
assert "--effort high " in flags
|
||||
assert "--effort xhigh " in flags
|
||||
assert "--fallback-model claude-sonnet-4-6 " in flags
|
||||
|
||||
|
||||
def test_flags_absent_when_empty(monkeypatch):
|
||||
def test_flags_effort_per_role(monkeypatch):
|
||||
"""developer -> --effort xhigh; tester -> --effort medium (mirrors _spawn)."""
|
||||
assert "--effort xhigh " in _build_flags("", resolve_agent_effort("developer"), "")
|
||||
assert "--effort medium " in _build_flags("", resolve_agent_effort("tester"), "")
|
||||
|
||||
|
||||
def test_flags_absent_when_effort_empty():
|
||||
"""When the resolved effort is empty, --effort is omitted entirely. Mirrors the
|
||||
`f"--effort {effort} " if effort else ""` branch in _spawn (AC-3 negative case)."""
|
||||
flags = _build_flags("", "", "")
|
||||
assert flags == ""
|
||||
assert "--effort" not in flags
|
||||
|
||||
|
||||
def test_flags_absent_when_model_empty(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_model_default", "")
|
||||
monkeypatch.setattr(settings, "agent_model_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_fallback_model", "")
|
||||
model = resolve_agent_model("developer")
|
||||
effort = resolve_agent_effort("developer")
|
||||
fb = settings.agent_fallback_model
|
||||
flags = _build_flags(model, effort, fb)
|
||||
flags = _build_flags(model, "", fb)
|
||||
assert flags == ""
|
||||
assert "--model" not in flags
|
||||
assert "--effort" not in flags
|
||||
assert "--fallback-model" not in flags
|
||||
assert "--fallback-model" not in flags
|
||||
|
||||
Reference in New Issue
Block a user