Compare commits

...

24 Commits

Author SHA1 Message Date
132206d2fc tester(ET): auto-commit from tester run_id=424
All checks were successful
CI / test (push) Successful in 24s
CI / test (pull_request) Successful in 23s
2026-06-09 02:23:27 +03:00
fb9a77dbfb reviewer(ET): auto-commit from reviewer run_id=423
All checks were successful
CI / test (push) Successful in 27s
CI / test (pull_request) Successful in 30s
2026-06-09 02:21:39 +03:00
e729b7117f fix(reconciler): terminal-skip + state_uuid dedup on F-1 path
All checks were successful
CI / test (push) Successful in 24s
CI / test (pull_request) Successful in 24s
Закрывает F-1-пробел ORCH-068: терминал-исключение и in-memory dedup
(изначально только F-2) распространены на gate-side путь реконсилятора,
устраняя ложное «🔧 reconciler: ET-002 done разблокирована (потерян
webhook)» (особенно после рестарта).

- D1: новый _resolve_issue_status — один сетевой резолв Plane-статуса
  задачи за тик (states, groups, state_uuid) после дешёвых локальных
  гардов; never-raise -> ({}, {}, None) при сбое.
- D2: безусловный терминал-скип ДО Guard 2 (группа Plane completed/
  cancelled, fallback на логические ключи done/cancelled, либо стадия в
  БД орка ∈ {done, cancelled}); skipped_terminal_total++, не подчинён
  reconcile_skip_blocked_enabled.
- D3: _is_blocked_or_needs_input переиспользует резолв D1 (опц. аргументы,
  _UNSET -> самостоятельный резолв для прямых/легаси-вызовов; 1:1).
- D4: вызов _note_unblock на F-1 теперь передаёт state_uuid -> dedup
  работает на обоих путях (deduped_total++ на повторе).

Анти-регресс: легитимный unblock не-терминальной застрявшей задачи
по-прежнему advance + один Telegram. STAGE_TRANSITIONS / QG_CHECKS /
схема БД / сигнатуры advance_*/_note_unblock / форма status() / новые
флаги — без изменений; never-raise сохранён.

Тесты: tests/test_reconciler.py TC-86-01..09/11,
tests/test_reconciler_plane.py TC-86-10. Полный прогон зелёный (1069).

Refs: ORCH-086
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 02:17:59 +03:00
edf94c1501 architect(ET): auto-commit from architect run_id=421
All checks were successful
CI / test (push) Successful in 25s
2026-06-09 02:08:23 +03:00
8e322e1626 analyst(ET): auto-commit from analyst run_id=419
All checks were successful
CI / test (push) Successful in 24s
2026-06-09 01:50:19 +03:00
1844a26e73 docs: init ORCH-086 business request
All checks were successful
CI / test (push) Successful in 24s
2026-06-09 01:45:36 +03:00
92817889c4 Merge pull request 'fix: disable Telegram link-preview in tracker notifications (ORCH-080)' (#85) from feature/ORCH-080-orch-52g-telegram-link-preview into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 01:38:16 +03:00
deploy-finalizer
baf7860822 deploy(ORCH-036): finalize SUCCESS for ORCH-080
All checks were successful
CI / test (push) Successful in 24s
CI / test (pull_request) Successful in 24s
2026-06-09 01:38:15 +03:00
2cf40c1af9 tester(ET): auto-commit from tester run_id=417
All checks were successful
CI / test (push) Successful in 28s
CI / test (pull_request) Successful in 25s
2026-06-09 01:32:53 +03:00
44ef0bb570 reviewer(ET): auto-commit from reviewer run_id=416 2026-06-09 01:32:53 +03:00
d826eacfcf fix: disable Telegram link-preview in tracker notifications (ORCH-080)
Add "disable_web_page_preview": True to the JSON payload of both
low-level Telegram primitives — send_telegram (POST /sendMessage) and
edit_telegram (POST /editMessageText). Telegram no longer expands the
Plane "Modern project management" link-preview banner under every
tracker card (bump/edit) and notify/alert message, which the default
bump mode (ORCH-067) was duplicating on each transition.

Single-point fix at the primitive level — all consumers
(update_task_tracker, notify_approve_requested, notify_error, stage
alerts from launcher/stage_engine) inherit it without code changes.
parse_mode: HTML is preserved so the ORCH-NNN issue link stays
clickable; disable_notification, bump/edit logic, the one-card-per-task
invariant, return contracts and never-raise are untouched. Unconditional,
no kill-switch (ADR-001).

Tests: tests/test_link_preview_disabled.py (TC-01..06). Docs: CHANGELOG,
CLAUDE.md, docs/architecture/README.md (Notifications component).

Refs: ORCH-080
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 01:32:53 +03:00
a482b36dae architect(ET): auto-commit from architect run_id=414 2026-06-09 01:32:53 +03:00
f452626bb8 analyst(ET): auto-commit from analyst run_id=413 2026-06-09 01:32:53 +03:00
b46fc6e51b docs: init ORCH-080 business request 2026-06-09 01:32:53 +03:00
140827f4da docs(ORCH-080): merge staging gate log (staging_status: SUCCESS)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 01:32:32 +03:00
fc29ba76ec Merge pull request 'feat(merge-verify): guarantee idempotent open code-PR before merge_pr (ORCH-082)' (#82) from feature/ORCH-082-orch-81-pr-merge-verify-hold into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 01:06:25 +03:00
deploy-finalizer
9834dae108 deploy(ORCH-036): finalize SUCCESS for ORCH-082
All checks were successful
CI / test (push) Successful in 24s
CI / test (pull_request) Successful in 24s
2026-06-09 01:01:56 +03:00
039322001a tester(ET): auto-commit from tester run_id=411
All checks were successful
CI / test (push) Successful in 27s
CI / test (pull_request) Successful in 25s
2026-06-09 00:57:08 +03:00
1997376eb5 reviewer(ET): auto-commit from reviewer run_id=410 2026-06-09 00:57:08 +03:00
0ab6a33ef5 feat(merge-verify): guarantee idempotent open code-PR before merge_pr (ORCH-082)
Close the missing invariant "by merge-verify time the branch has an open
code-PR". The pipeline created a PR only on the developer path with a fresh
worktree commit (launcher._ensure_pr), so a branch (e.g. after a manual main
restore) could reach the deploy->done merge-verify under-gate PR-less ->
merge_pr returned "no open PR" -> a FALSE HOLD (ORCH-074 incident).

- merge_gate.ensure_open_pr(repo, branch) -> (status, detail): idempotent
  leaf-actor (never-raise). GET open PRs filtered head==branch AND base==main
  (identical to merge_pr/ORCH-073 FR-3 — auto docs-PR is not a code-PR) ->
  existed; else POST -> created; 409/422 race -> re-GET -> existed (no dup);
  any other error -> failed.
- stage_engine._handle_merge_verify: врезка after validated_revision and
  BEFORE merge_pr. created|existed -> proceed; failed -> honest HOLD via new
  _hold_pr_create_failed (note "pr-create-failed-hold", text distinguishable
  from the not-merged HOLD; task stays on deploy, NO rollback).
- launcher._ensure_pr delegated to ensure_open_pr (single PR-creation path,
  shared head==branch & base==main filter); the developer-only trigger is
  unchanged.
- ORCH-073 protection untouched & authoritative: merge is confirmed ONLY by
  verify_merged_to_main (SHA-in-main) + check_main_regression. Real un-merged
  code still HOLDs.
- Kill-switch ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED (default true); scope =
  merge_verify_applies (self-hosting / merge_verify_repos); non-self -> no-op;
  false -> ORCH-074 behaviour 1:1. No DB migration; main never push/force-push.
- Append ORCH-082 marker to MAIN_REGRESSION_MARKERS (append-only convention).
- conftest defaults the autocreate flag OFF (mirrors merge_verify_enabled) so
  unrelated deploy->done tests stay 1:1 (no network).

Tests: tests/test_orch082_ensure_pr.py (TC-01..05),
tests/test_orch082_merge_verify_autocreate.py (TC-06..12). Docs: README
merge-verify block (ORCH-082), CHANGELOG, .env.example.

Refs: ORCH-082

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:57:08 +03:00
74269b467c architect(ET): auto-commit from architect run_id=408 2026-06-09 00:57:08 +03:00
781f9df26c analyst(ET): auto-commit from analyst run_id=407 2026-06-09 00:57:08 +03:00
c0715ad55b docs: init ORCH-082 business request 2026-06-09 00:57:08 +03:00
7ee528ad7b Merge pull request 'docs(ORCH-082): staging gate log — SUCCESS' (#83) from docs/ORCH-082-staging-log into main 2026-06-09 00:56:37 +03:00
49 changed files with 3163 additions and 46 deletions

View File

@@ -123,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

View File

@@ -54,6 +54,10 @@ created → analysis → architecture → development → review → testing →
- **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех
уведомлениях (`notify_*`, alert'ы стадий) рендерится как `<a href=…>` на issue в Plane;
fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает.
- **Без link-preview (ORCH-080):** оба примитива (`send_telegram`/`edit_telegram`) шлют
payload с `disable_web_page_preview: True` — баннер Plane («Modern project management»)
под кликабельной ссылкой `ORCH-NNN` больше не разворачивается ни в карточке (`bump`/`edit`),
ни в notify/alert-сообщениях. `parse_mode: HTML` сохранён → ссылка остаётся кликабельной.
- Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification`
(карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются.

View File

@@ -13,7 +13,7 @@
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). Все алерты, упоминающие `work_item_id`, делают номер кликабельным. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7.
- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
@@ -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 —
@@ -318,6 +351,18 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A —
запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард
retry-count проверяется первым (дёшево, локальный SQL).
**ORCH-086 (закрытие F-1-пробела ORCH-068):** терминал-исключение и `state_uuid`-dedup
(изначально только F-2) распространены на F-1. После дешёвых локальных гардов F-1 делает
**один** резолв Plane-статуса задачи на тик (общий fetch для Guard 2 + терминал-скипа +
`_note_unblock`); терминальная задача (группа Plane `completed`/`cancelled`, fallback —
логические ключи `done`/`cancelled`, ЛИБО стадия в БД орка ∈ `{done, cancelled}`) →
**безусловный** ранний скип (`skipped_terminal_total++`, без `advance`/уведомления; не подчинён
`reconcile_skip_blocked_enabled`). Вызов `_note_unblock` на F-1 теперь передаёт `state_uuid`
in-memory dedup работает на обоих путях (страховка от повтора после рестарта). Лечит
периодическое ложное «ET-002 done разблокирована (потерян webhook)» для терминальных в Plane
задач (enduro/orchestrator), сохраняя легитимный unblock реально застрявшей не-терминальной
задачи. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/сигнатуры/новые флаги — без изменений. Детали —
`docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md`.
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
**ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane

View File

@@ -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.

View File

@@ -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 — лишь механизм слияния, ревью не обходится.

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH-52g: убрать Telegram link-preview (логотип Plane) в уведомлениях трекера
Work Item ID: ORCH-080
## Description
TBD

View File

@@ -0,0 +1,73 @@
# 01-BRD — ORCH-080: убрать Telegram link-preview (логотип Plane) в уведомлениях трекера
Work Item ID: ORCH-080
Эпик: ORCH-052 (под-задача ORCH-52g)
Тип: Доработка (UX уведомлений)
Приоритет: LOW (косметика)
Зона: `src/notifications.py`
## 1. Контекст и проблема
Каждая задача в Telegram сопровождается одной live-карточкой трекера (`src/notifications.py`,
ORCH-042/066/067). С ORCH-067 в карточке появился **кликабельный номер задачи**
`<a href="https://plane.mva154.duckdns.org/.../issues/<id>/">ORCH-NNN</a>`.
Telegram по умолчанию разворачивает **link-preview** (web page preview) для первой ссылки
в сообщении. Из-за ссылки на Plane под каждым сообщением трекера раскрывается крупный
баннер-превью **«Plane — Modern project management»**.
**Жалоба (Слава, 08.06):** баннер уродует ленту чата и дублируется на каждой задаче/каждом
обновлении карточки (особенно заметно в дефолтном режиме `bump`, где карточка пересоздаётся
на каждом переходе).
## 2. Диагностика (код-аудит `src/notifications.py`)
| Функция | Эндпоинт | Текущий JSON-payload | Превью |
|---------|----------|----------------------|--------|
| `send_telegram()` (стр. 52-62) | `POST /sendMessage` | `chat_id`, `text`, `parse_mode: HTML`, `disable_notification` | **разворачивается** (нет `disable_web_page_preview`) |
| `edit_telegram()` (стр. 165-174) | `POST /editMessageText` | `chat_id`, `message_id`, `text`, `parse_mode: HTML` | **разворачивается** (нет `disable_web_page_preview`) |
Причина баннера: оба payload **не содержат** ключ `disable_web_page_preview`. Telegram Bot API
по умолчанию (отсутствие ключа) включает превью.
`delete_telegram()` (`/deleteMessage`) превью не порождает — правки не требует.
## 3. Бизнес-цель
Карточка трекера и уведомления в Telegram **не должны** показывать баннер link-preview Plane,
при этом ссылка на задачу **остаётся кликабельной**.
## 4. Бизнес-требования
- **BR-1.** В payload `sendMessage` (`send_telegram`) присутствует `disable_web_page_preview: True`.
- **BR-2.** В payload `editMessageText` (`edit_telegram`) присутствует `disable_web_page_preview: True`.
- **BR-3.** Баннер-превью Plane больше не появляется ни под карточкой трекера (оба режима
`bump`/`edit`), ни под отдельными notify-сообщениями, которые идут через `send_telegram`
(`notify_approve_requested`, `notify_error`, alert'ы стадий) — все они используют тот же
низкоуровневый примитив.
- **BR-4.** Кликабельная ссылка `<a href>` на задачу в Plane сохраняется (`parse_mode: HTML`
не меняется).
- **BR-5.** Контракт **never-raise** сохранён: отправка/редактирование никогда не валит
оркестратор; `pytest` зелёный.
## 5. Не-цели (вне скоупа)
- Не менять текст/формат/верстку карточки.
- Не трогать `parse_mode` (HTML нужен для `<a href>`).
- Не трогать bump/edit-логику (`update_task_tracker`), репойнт `tracker_message_id`,
delete-семантику.
- Не вводить флаги/конфиг — поведение «без превью» безусловное (превью никому не нужно).
- Не трогать схему БД.
## 6. Заинтересованные лица
- **Слава (Owner)** — инициатор, конечный наблюдатель ленты Telegram.
## 7. Грабли / координация
- Файл `src/notifications.py` затрагивает также ORCH-067 (и потенциально другие задачи эпика).
Сверить, что правки (две строки) не конфликтуют при merge.
- Один репозиторий с ORCH-74 → по ORCH-026 действует сериализация merge.
Запускать **после** того как ORCH-74 доедет в `main` (или когда конвейер свободен),
чтобы не плодить параллельный merge в `orchestrator`.
- Деплой — штатный через **Confirm Deploy** (self-hosting, ORCH-059).

View File

@@ -0,0 +1,102 @@
# 02-TRZ — ORCH-080: убрать Telegram link-preview в уведомлениях трекера
Work Item ID: ORCH-080
Зона изменений: `src/notifications.py` (две строки)
## 1. Задействованные модули `src/`
- `src/notifications.py`**единственный** изменяемый модуль:
- `send_telegram(text, disable_notification=False)` — обёртка `POST .../sendMessage`.
- `edit_telegram(message_id, text)` — обёртка `POST .../editMessageText`.
Косвенно затронуты (поведение улучшается без изменения их кода — они вызывают изменённые
примитивы): `update_task_tracker` (bump+edit), `notify_approve_requested`, `notify_error`,
а также вызовы `send_telegram` из `launcher`/`stage_engine` (alert'ы деплоя/падений).
## 2. Изменения кода
### 2.1. `send_telegram()` — добавить ключ в JSON-payload `httpx.post`
В словаре `json={...}` вызова `sendMessage` (текущие стр. 55-60) добавить строку:
```python
"disable_web_page_preview": True,
```
Итоговый payload:
```python
json={
"chat_id": s.telegram_chat_id,
"text": text,
"parse_mode": "HTML",
"disable_notification": disable_notification,
"disable_web_page_preview": True,
},
```
### 2.2. `edit_telegram()` — добавить ключ в JSON-payload `httpx.post`
В словаре `json={...}` вызова `editMessageText` (текущие стр. 168-173) добавить строку:
```python
"disable_web_page_preview": True,
```
Итоговый payload:
```python
json={
"chat_id": s.telegram_chat_id,
"message_id": message_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
```
> Примечание: Telegram Bot API исторически принимает top-level `disable_web_page_preview`
> для `sendMessage`/`editMessageText` (актуальная схема также поддерживает
> `link_preview_options.is_disabled`, но top-level флаг остаётся валиден и совместим).
> Используем top-level флаг — минимальная, обратносовместимая правка, как указано в задаче.
## 3. Изменения API
Нет изменений внутреннего HTTP API оркестратора. Меняется только тело исходящих запросов к
Telegram Bot API (добавлен один булев ключ в payload двух методов).
## 4. Изменения схемы БД
Нет.
## 5. Требования к новым QG checks
Нет. Новые Quality Gate проверки не вводятся.
## 6. Конфиг / флаги
Нет. Поведение «без превью» — безусловное (kill-switch не требуется: превью трекера
не нужно никому, риск регрессии нулевой; правка обратимая одной строкой).
`parse_mode`, `disable_notification`, bump/edit-логика — без изменений.
## 7. Артефакты, обновляемые по pipeline
- `CHANGELOG.md` — запись в `## [Unreleased]` (тип `fix:` — косметика UX уведомлений).
- Документация: правка `src/notifications.py` затрагивает поведение, описанное в
`CLAUDE.md` (раздел «Нотификации / Telegram live-tracker») и
`docs/architecture/README.md` (компонент Notifications). Достаточно короткой ремарки,
что карточка/уведомления шлются без web-page-preview (по желанию архитектора — определить
объём в ADR; ADR не обязателен для столь малой косметики, решение за архитектором).
## 8. Контракты-инварианты (не нарушать)
- **never-raise**: обе функции по-прежнему ловят все исключения (`try/except: pass`/`return`)
и не валят оркестратор.
- Возвращаемые значения не меняются: `send_telegram``message_id|None`,
`edit_telegram``EDIT_*`.
- `parse_mode: "HTML"` сохранён в обоих payload (иначе `<a href>` сломается).
- `disable_notification` в `send_telegram` сохранён (карточка тихая).
- Инвариант «одна карточка на задачу» (bump/edit) не затрагивается.
## 9. Commit / ветка
- Ветка: `feature/ORCH-080-orch-52g-telegram-link-preview` (существует).
- Commit: `fix: disable Telegram link-preview in tracker notifications (ORCH-080)`.

View File

@@ -0,0 +1,59 @@
# 03-Acceptance Criteria — ORCH-080
Work Item ID: ORCH-080
Каждый критерий имеет явное условие PASS/FAIL.
## AC-1 — `disable_web_page_preview` в payload `sendMessage`
- **PASS:** JSON-payload вызова `httpx.post(.../sendMessage)` в `send_telegram()` содержит
ключ `"disable_web_page_preview"` со значением `True`.
- **FAIL:** ключ отсутствует или `False`.
- **Проверка:** unit-тест (мок `httpx`) инспектирует `httpx.post.call_args.kwargs["json"]`.
## AC-2 — `disable_web_page_preview` в payload `editMessageText`
- **PASS:** JSON-payload вызова `httpx.post(.../editMessageText)` в `edit_telegram()` содержит
ключ `"disable_web_page_preview"` со значением `True`.
- **FAIL:** ключ отсутствует или `False`.
- **Проверка:** unit-тест (мок `httpx`) инспектирует `httpx.post.call_args.kwargs["json"]`.
## AC-3 — баннер link-preview Plane исчез в карточке трекера
- **PASS:** в реальном чате Telegram карточка трекера задачи (режимы `bump` и `edit`)
больше не показывает баннер «Plane — Modern project management».
- **FAIL:** баннер всё ещё разворачивается.
- **Проверка:** ручная верификация на staging (8501) после деплоя — наблюдение карточки в
Telegram. Автоматически косвенно покрыто AC-1/AC-2 (payload содержит флаг).
## AC-4 — ссылка на задачу остаётся кликабельной
- **PASS:** в карточке/уведомлениях номер задачи `ORCH-NNN` остаётся кликабельной ссылкой
`<a href=...>` на issue в Plane; `parse_mode: "HTML"` сохранён в обоих payload.
- **FAIL:** `parse_mode` изменён/удалён, либо ссылка перестала рендериться как `<a href>`.
- **Проверка:** unit-тест проверяет, что `"parse_mode": "HTML"` присутствует в обоих payload;
существующие тесты ссылок (`test_notify_issue_links.py`) остаются зелёными.
## AC-5 — сохранены существующие поля payload
- **PASS:** `send_telegram` payload по-прежнему содержит `chat_id`, `text`, `parse_mode`,
`disable_notification`; `edit_telegram` payload — `chat_id`, `message_id`, `text`,
`parse_mode`. Возвращаемые значения функций не изменились
(`send_telegram → message_id|None`, `edit_telegram → EDIT_*`).
- **FAIL:** любое из перечисленных полей удалено/переименовано, либо изменился контракт
возврата.
- **Проверка:** unit-тесты payload + существующие тесты трекера/классификации исходов.
## AC-6 — never-raise сохранён, pytest зелёный
- **PASS:** при сетевой/HTTP-ошибке `send_telegram`/`edit_telegram` не бросают исключение
(возврат `None`/`EDIT_FAILED`); вся сюита `pytest tests/ -q` зелёная.
- **FAIL:** любое исключение наружу или красный pytest.
- **Проверка:** существующие тесты never-raise (`test_resilience.py`,
`test_telegram_tracker.py`) + полный прогон.
## AC-7 — документация обновлена в том же PR
- **PASS:** `CHANGELOG.md` содержит запись об ORCH-080; при необходимости — короткая ремарка
в `CLAUDE.md`/`docs/architecture/README.md` о подавлении link-preview.
- **FAIL:** функционал изменён, документация не обновлена (Reviewer → REQUEST_CHANGES).

View File

@@ -0,0 +1,76 @@
work_item: ORCH-080
description: >
Подавление Telegram link-preview (disable_web_page_preview: True) в payload
send_telegram (sendMessage) и edit_telegram (editMessageText). Сохранить
parse_mode HTML, disable_notification, never-raise и контракты возврата.
tests:
- id: TC-01
type: unit
description: >
send_telegram() кладёт "disable_web_page_preview": True в JSON-payload
httpx.post(.../sendMessage). Проверка через мок httpx и инспекцию
httpx.post.call_args.kwargs["json"].
module: tests/test_link_preview_disabled.py
expected: PASS
- id: TC-02
type: unit
description: >
edit_telegram() кладёт "disable_web_page_preview": True в JSON-payload
httpx.post(.../editMessageText). Проверка через мок httpx и инспекцию
payload.
module: tests/test_link_preview_disabled.py
expected: PASS
- id: TC-03
type: unit
description: >
Регрессия parse_mode: оба payload (sendMessage и editMessageText)
по-прежнему содержат "parse_mode": "HTML" — ссылка <a href> остаётся
кликабельной (AC-4).
module: tests/test_link_preview_disabled.py
expected: PASS
- id: TC-04
type: unit
description: >
Регрессия полей send_telegram: payload содержит chat_id, text,
parse_mode, disable_notification; disable_notification прокидывается
из аргумента (True/False) без изменений (AC-5).
module: tests/test_link_preview_disabled.py
expected: PASS
- id: TC-05
type: unit
description: >
Контракты возврата не изменились: send_telegram возвращает message_id
при ok:true, None при отсутствии креденшелов/ошибке; edit_telegram
возвращает EDIT_OK при ok:true (AC-5, AC-6).
module: tests/test_link_preview_disabled.py
expected: PASS
- id: TC-06
type: unit
description: >
never-raise: при httpx.post бросающем исключение send_telegram->None и
edit_telegram->EDIT_FAILED, без проброса исключения (AC-6).
module: tests/test_link_preview_disabled.py
expected: PASS
- id: TC-07
type: integration
description: >
Полный прогон существующей сюиты трекера/уведомлений остаётся зелёным
(нет регрессий bump/edit-логики, классификации исходов, ссылок):
pytest tests/test_telegram_tracker.py tests/test_tracker_bump.py
tests/test_notify_issue_links.py tests/test_resilience.py.
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-08
type: integration
description: >
Вся сюита pytest tests/ -q зелёная (общая регрессия, AC-6).
module: tests/
expected: PASS

View File

@@ -0,0 +1,63 @@
# ADR-001: Подавление Telegram link-preview в низкоуровневых примитивах нотификаций
## Статус
Accepted
## Контекст
С ORCH-067 карточка трекера и notify-сообщения несут кликабельный номер задачи
`<a href="https://plane.mva154.duckdns.org/.../issues/<id>/">ORCH-NNN</a>`. Telegram
Bot API по умолчанию (при отсутствии ключа `disable_web_page_preview`) разворачивает
web-page-preview для первой ссылки в сообщении — под каждым сообщением трекера
раскрывается баннер «Plane — Modern project management». В дефолтном режиме `bump`
(ORCH-067) карточка пересоздаётся на каждом переходе, поэтому баннер дублируется на
каждой задаче и каждом обновлении, засоряя ленту (жалоба Owner, 08.06).
Код-аудит (`src/notifications.py`) подтвердил причину: JSON-payload обоих
низкоуровневых примитивов — `send_telegram()` (`POST /sendMessage`, стр. 55-60) и
`edit_telegram()` (`POST /editMessageText`, стр. 168-173) — **не содержит** ключ
`disable_web_page_preview`. Все вышестоящие нотификации (`update_task_tracker` в обоих
режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из
`launcher`/`stage_engine`) проходят через эти два примитива.
## Решение
Добавить `"disable_web_page_preview": True` в JSON-payload `httpx.post` обоих примитивов:
`send_telegram()` и `edit_telegram()`. Изменение — **на уровне низкоуровневого
примитива**, а не на уровне каждого вызова, потому что:
1. **Единая точка** — все исходящие сообщения трекера/нотификаций идут через эти две
функции; правка двух строк гасит баннер у ВСЕХ потребителей (карточка `bump`/`edit`,
notify-хелперы, alert'ы) без изменения их кода.
2. **Безусловно, без флага** — превью Plane не нужно никому (это не данные, а навигация
по ссылке, которая остаётся кликабельной). Kill-switch не вводится: риск регрессии
нулевой, правка обратима одной строкой. Это согласуется с принципом «минимум
зависимостей/конфигурации».
3. **Top-level флаг, а не `link_preview_options.is_disabled`** — top-level
`disable_web_page_preview` остаётся валиден и обратносовместим в Bot API; это
минимальная правка без введения вложенной структуры.
`parse_mode: "HTML"` сохраняется в обоих payload (иначе `<a href>` перестанет
рендериться — ссылка должна остаться кликабельной). `disable_notification`,
bump/edit-логика, repoint `tracker_message_id`, delete-семантика, контракты возврата
(`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) — не затрагиваются.
## Последствия
**Плюсы:**
- Баннер link-preview исчезает под карточкой трекера (оба режима) и под всеми
notify/alert-сообщениями — одна правда в двух примитивах.
- Ссылка на задачу остаётся кликабельной (HTML сохранён).
- Нулевой риск: ключ аддитивный, контракты примитивов и инвариант «одна карточка на
задачу» не меняются; `never-raise` (`try/except`) сохранён.
**Минусы / ограничения:**
- Поведение безусловное — нет конфигурации «вернуть превью». Сознательный выбор:
превью трекера не имеет ценности, флаг был бы лишней поверхностью.
**Не затрагивается:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `parse_mode`,
`disable_notification`, транспортные хелперы `delete_telegram`/repoint-логика. Глобальный
ADR не требуется — решение локально для `src/notifications.py`, не сквозное.
## Self-hosting
Изменение не требует немедленного рестарта прод-контейнера и не меняет топологию.
Деплой — штатный через staging (8501) → `Confirm Deploy` (ORCH-059). По ORCH-026
(сериализация merge одного репо) задача мержится после освобождения конвейера
`orchestrator` (координация с ORCH-074 — см. BRD §7).

View File

@@ -0,0 +1,22 @@
# 10-Tech Risks — ORCH-080
Work Item ID: ORCH-080
Зона: `src/notifications.py` (две строки в `send_telegram`/`edit_telegram`)
Косметическая правка UX (LOW). Топология, схема БД, стадии, QG — не меняются.
Риск регрессии оценён как **нулевой**; ниже — остаточные пункты для внимания.
| # | Риск | Вероятность | Влияние | Митигация |
|---|------|-------------|---------|-----------|
| R-1 | Опечатка ключа/значения (`disable_web_page_preview`) — баннер не гаснет | Низкая | Низкое (косметика) | unit-тест AC-1/AC-2 инспектирует `httpx.post.call_args.kwargs["json"]`; ручная верификация на staging (AC-3) |
| R-2 | Случайное удаление `parse_mode: "HTML"` → ссылка `<a href>` ломается | Очень низкая | Среднее (теряется кликабельность) | AC-4: unit-тест на наличие `parse_mode: "HTML"` в обоих payload; `test_notify_issue_links.py` остаётся зелёным |
| R-3 | Merge-конфликт с ORCH-067/ORCH-074 в `src/notifications.py` | Низкая | Низкое | По ORCH-026 сериализация merge одного репо; запуск после доезда ORCH-74 в `main` (BRD §7); pre-merge rebase (ORCH-043) |
| R-4 | Регрессия контракта возврата примитивов (`message_id|None` / `EDIT_*`) | Очень низкая | Среднее | Правка строго аддитивна (новый ключ в payload), возвраты не трогаются; AC-5 + существующие тесты трекера |
| R-5 | Telegram депрекейтит top-level `disable_web_page_preview` в пользу `link_preview_options` | Очень низкая | Низкое (forward-compat) | Top-level флаг остаётся валиден и обратносовместим; миграция на `link_preview_options.is_disabled` — отдельная задача при необходимости |
## Инварианты, которые НЕЛЬЗЯ нарушить
- `never-raise` обоих примитивов (`try/except` сохранён).
- `parse_mode: "HTML"` в обоих payload (иначе `<a href>` ломается).
- `disable_notification` в `send_telegram` (карточка тихая).
- Инвариант «одна карточка на задачу» (bump/edit) — не затрагивается.
- Контракты возврата: `send_telegram → message_id|None`, `edit_telegram → EDIT_*`.

View File

@@ -0,0 +1,72 @@
---
type: review
work_item_id: ORCH-080
verdict: APPROVED
version: 1
---
# Review ORCH-080
## Summary
Задача убирает баннер Telegram link-preview («Plane — Modern project management»),
который разворачивался под кликабельной ссылкой `ORCH-NNN` в карточке трекера и
во всех notify/alert-сообщениях. Решение точно соответствует TRZ и ADR-001:
добавлен ключ `"disable_web_page_preview": True` в JSON-payload обоих
низкоуровневых примитивов `send_telegram` (`POST /sendMessage`) и `edit_telegram`
(`POST /editMessageText`) — единая точка для всех потребителей, без kill-switch,
без изменения контрактов. Изменение минимально (2 строки + комментарии),
аддитивно и обратимо.
Проверены все четыре оси (ТЗ, ADR, качество кода, тесты) + документация. Findings
уровней P0/P1/P2 — нет.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
## Соответствие ТЗ и AC
- TRZ §2.1/§2.2 — ключ добавлен в оба payload в точности как предписано. ✅
- AC-1 — `disable_web_page_preview: True` в `sendMessage` payload (TC-01). ✅
- AC-2 — то же в `editMessageText` payload (TC-02). ✅
- AC-3 — баннер исчезает (ручная верификация на staging; косвенно покрыто AC-1/AC-2). ✅
- AC-4 — `parse_mode: "HTML"` сохранён в обоих payload, ссылка кликабельна (TC-03);
`tests/test_notify_issue_links.py` зелёный. ✅
- AC-5 — поля `chat_id/text/parse_mode/disable_notification` (send) и
`chat_id/message_id/text/parse_mode` (edit) сохранены; контракты возврата
(`message_id|None`, `EDIT_*`) не изменились (TC-04/TC-05). ✅
- AC-6 — never-raise сохранён (TC-06); полный прогон `pytest tests/ -q`**1058 passed**. ✅
- AC-7 — документация обновлена в том же PR (см. ниже). ✅
## Соответствие ADR
ADR-001 (Accepted): правка на уровне примитива (а не каждого вызова), безусловно
без флага, top-level `disable_web_page_preview` вместо `link_preview_options`,
`parse_mode: HTML` сохранён, контракты и инвариант «одна карточка на задачу» не
тронуты. Реализация соответствует решению 1:1. Глобальные ADR не нарушены
(`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений). ✅
## Качество кода
- Изменение минимальное, целевое; комментарии ссылаются на ORCH-080 и поясняют цель.
- `try/except` never-raise в обеих функциях не затронут; пути без кредов и контракты
возврата сохранены.
- Тесты содержательные: инспектируют реальный payload через мок `httpx`
(`call_args.kwargs["json"]`), покрывают флаг, регрессию `parse_mode`/полей,
контракты возврата и never-raise (TC-01..06). Нет тривиальных/пустых тестов.
- Security: ключ булев, новых поверхностей/секретов нет.
## Документация
Изменён `src/` (поведение исходящих Telegram-запросов) → документация обновлена в
том же PR, как требует CLAUDE.md §2/§6:
- `CHANGELOG.md` — запись в `## [Unreleased]` (тип `fix:`). ✅
- `CLAUDE.md` — раздел «Нотификации / Telegram live-tracker» дополнен пунктом
«Без link-preview (ORCH-080)». ✅
- `docs/architecture/README.md` — компонент Notifications дополнен ремаркой ORCH-080. ✅
- ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md` заведён. ✅
Документация соответствует коду; расхождений нет.

View File

@@ -0,0 +1,66 @@
---
type: test-report
work_item_id: ORCH-080
result: PASS
---
# Test Report — ORCH-080
Подавление Telegram link-preview (`disable_web_page_preview: True`) в `send_telegram`
(`sendMessage`) и `edit_telegram` (`editMessageText`). Сохранены `parse_mode: HTML`,
`disable_notification`, never-raise и контракты возврата.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-09
- Ветка: `feature/ORCH-080-orch-52g-telegram-link-preview`
- Review verdict: APPROVED (`12-review.md`)
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | OK (ORCH-080 = task #62, stage `testing`) |
| `GET /queue` | OK (breaker `closed`, preflight_ok, reconcile/reaper enabled) |
## Результаты тестов
| TC ID | Описание | Тест(ы) | Результат |
|-------|----------|---------|-----------|
| TC-01 | `disable_web_page_preview: True` в payload `sendMessage` (AC-1) | `test_send_telegram_disables_link_preview` | PASS |
| TC-02 | `disable_web_page_preview: True` в payload `editMessageText` (AC-2) | `test_edit_telegram_disables_link_preview` | PASS |
| TC-03 | Регрессия `parse_mode: HTML` в обоих payload (AC-4) | `test_send_telegram_keeps_parse_mode_html`, `test_edit_telegram_keeps_parse_mode_html` | PASS |
| TC-04 | Регрессия полей `send_telegram` + проброс `disable_notification` (AC-5) | `test_send_telegram_preserves_existing_fields`, `test_send_telegram_disable_notification_default_false`, `test_edit_telegram_preserves_existing_fields` | PASS |
| TC-05 | Контракты возврата (`message_id`/`None`/`EDIT_OK`) (AC-5/AC-6) | `test_send_telegram_returns_message_id`, `test_send_telegram_returns_none_without_creds`, `test_edit_telegram_returns_edit_ok` | PASS |
| TC-06 | never-raise → `None`/`EDIT_FAILED` без проброса (AC-6) | `test_send_telegram_never_raises`, `test_edit_telegram_never_raises` | PASS |
| TC-07 | Регресс сюиты трекера/уведомлений (bump/edit, ссылки, resilience) | `test_telegram_tracker.py`, `test_tracker_bump.py`, `test_notify_issue_links.py`, `test_resilience.py` (+ `test_link_preview_disabled.py`) — 106 passed | PASS |
| TC-08 | Полная регрессия `pytest tests/ -q` (AC-6) | вся сюита — 1058 passed | PASS |
## Покрытие Acceptance Criteria
- AC-1 — TC-01 ✅
- AC-2 — TC-02 ✅
- AC-3 (баннер исчез в чате) — ручная верификация на staging (8501) после деплоя; автоматически косвенно покрыто AC-1/AC-2 (payload несёт флаг). Не блокирует тест-гейт.
- AC-4 — TC-03 + `test_notify_issue_links.py` зелёный ✅
- AC-5 — TC-04/TC-05 ✅
- AC-6 — TC-06 + полный прогон зелёный ✅
- AC-7 — документация (CHANGELOG/CLAUDE.md/architecture/ADR) проверена на review-стадии ✅
## Вывод pytest
Полная сюита:
```
1058 passed, 1 warning in 26.61s
```
Целевые файлы ORCH-080 (TC-01..07):
```
106 passed, 1 warning in 3.24s
```
(`test_link_preview_disabled.py` — 12 passed.)
Единственный warning — `PydanticDeprecatedSince20` в `src/config.py:5` (предсуществующий, не связан с ORCH-080).
## Итог
**PASS** — все автоматические тесты (TC-01..08) зелёные, smoke API OK, регрессий нет.
Задача готова к переходу на стадию `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-080
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,26 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T22:31:47Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
Run canonically **inside** the container via the Docker exec API (REST equivalent of
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`), so B6 reads the staging instance's own
process-env registry (ORCH-048, ADR-001).
**Exit code: 0 → advance.** All REAL pipeline checks passed (8/10 PASS).
- Block A (SMOKE): A1 /health, A2 /queue, A3 ORCH_STAGING=true — PASS
- Block B (ACCESS): B4 Plane sandbox, B5 Gitea sandbox (push=true), B6 registry isolation
(sandbox present, prod ET/ORCH absent) — PASS
- Block C (E2E): C7 create issue in SANDBOX, C8 trigger pipeline via /webhook/plane — PASS
- C9a/C9b — FAILED but **waived** (known sandbox-infra checks; depend on SANDBOX bot
accounts being project members, not on the pipeline). Tolerated under ORCH-061 because
every REAL check is green.
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH-81: конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD)
Work Item ID: ORCH-082
## Description
TBD

View 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 соблюдён.

View 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».

View 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:** любой тест падает.

View 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

View File

@@ -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:751753`):
```
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`

View 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.

View 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 отсутствуют, документация обновлена, тесты зелёные.

View 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`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-082
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH-86: reconciler шлёт в Telegram «ET-002 done разблокирована (потерян webhook)» периодически
Work Item ID: ORCH-086
## Description
TBD

View File

@@ -0,0 +1,70 @@
# 01-BRD — ORCH-086: reconciler шлёт ложное «ET-002 done разблокирована»
Work Item: **ORCH-086**
Тип: **Багфикс** (шум уведомлений / остаток livelock)
Приоритет: **MEDIUM**
Зона: `src/reconciler.py`
Связано: продолжение **ORCH-068** (тот фикс задеплоен, но НЕ закрыл этот путь), наследует контракты **ORCH-053 / ORCH-060 / ORCH-066**.
## 1. Контекст / проблема
В Telegram периодически (а особенно сразу после рестарта оркестратора) прилетает уведомление:
> 🔧 reconciler: ET-002 done разблокирована (потерян webhook)
Это **ложный шум**: задача `ET-002` (проект enduro-trails) давно завершена, реально ничего не разблокируется. Уведомление вводит наблюдателя в заблуждение (создаёт впечатление, что конвейер чинит застрявшую задачу, хотя ничего не происходит).
ORCH-068 уже починил аналогичный livelock на **F-2 (plane-side)**: добавил per-issue терминал-исключение (`_is_terminal_state`, группа Plane `completed`/`cancelled`) и in-memory dedup-guard по `issue_id→state_uuid`. Однако эти две защиты **не покрывают путь F-1 (gate-side)**.
## 2. Диагностика (код-аудит, golden source — текущий `src/reconciler.py`)
Уведомление отправляет `Reconciler._note_unblock()` (`reconciler.py` ~стр.444) через `send_telegram()` при `settings.reconcile_notify_unblock=True`.
Два механизма ORCH-068, которые ДОЛЖНЫ были его подавить, на пути F-1 не работают:
1. **Dedup-guard не срабатывает.** Guard ключуется по `state_uuid` и активен только когда `state_uuid is not None` (`_note_unblock`, стр.459463). Но вызов в F-1 (`_reconcile_gate_task`, стр.228):
```python
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
```
передаёт **только 2 аргумента, БЕЗ `state_uuid`** → ветка dedup пропускается → уведомление шлётся при каждом релевантном тике/старте. (В отличие от F-2, где все 4 вызова `_note_unblock` передают `state_uuid` — стр.394/400/407/416.)
2. **Терминал-скип не ловит этот путь.** Терминал-исключение ORCH-068 (`_is_terminal_state`, стр.327344) вызывается **только в F-2** (`_reconcile_plane_issue`, стр.362). В F-1 единственный «терминал-фильтр» — это `get_active_tasks_for_reconcile()` (`db.py` стр.193: `WHERE stage != 'done'`), который смотрит **только на стадию задачи в БД оркестратора** и НЕ знает о статусе задачи в Plane (группа `completed`/`cancelled`). Поэтому задача, которая в БД оркестратора стоит на НЕ-`done` стадии (дрейф), а в Plane уже `Done`, проходит фильтр.
### Почему `advance_if_gate_passed` считает ET-002 «продвинувшейся» (G1 — гипотеза, требует подтверждения в development)
Для enduro-trails (не self-hosting) условные гейты (`check_staging_status`, `check_deploy_status`, merge-gate, image-freshness, security-gate, merge-verify) — **no-op `(True, ...)`** (условность ORCH-35/43/58/71). Поэтому для enduro-задачи, чья стадия в БД оркестратора НЕ `done`, но застряла перед терминалом (например `deploy`), `advance_if_gate_passed` находит гейт зелёным (no-op) → вызывает `advance_stage(..., finished_agent=None)` → возвращает `result.advanced=True` (стр.227) → доходит до `_note_unblock`. Guard 2 (`_is_blocked_or_needs_input`, стр.230) задачу не спасает: его `skip_set` = `{blocked, needs_input, extra_waits}` и **НЕ содержит `done`/`cancelled`** → терминальная-в-Plane задача через него проходит. «Периодичность / при старте» объясняется отсутствием dedup (state_uuid не передан) + чистым in-memory состоянием нового процесса после рестарта (первый проход снова находит задачу).
> **Открытый вопрос для G1 (подтвердить в development по prod-БД/логам):** точная стадия `ET-002` в БД оркестратора в момент срабатывания (в quoted-сообщении фигурирует слово «done», но `get_active_tasks_for_reconcile` исключает `stage='done'` — значит стадия в БД иная либо аномальная). Фикс обязан быть **робастным независимо** от точной стадии: терминальность определяется по группе статуса Plane (как `_is_terminal_state`), а не по строковому совпадению стадии.
## 3. Бизнес-цели
- **G1.** Установить и задокументировать, почему F-1 (`advance_if_gate_passed`) доводит терминальную в Plane задачу (ET-002) до `_note_unblock` на каждом релевантном тике/старте.
- **G2.** Не слать unblock-уведомление для задач, УЖЕ терминальных (`done`/`cancelled`) в Plane (по группе статуса) и/или в оркестраторе — распространить терминал-скип ORCH-068 на путь F-1 (стр.228), а не только на F-2.
- **G3.** Передавать `state_uuid` в `_note_unblock` на **всех** путях (включая F-1) → in-memory dedup-guard работает везде (страховка от повтора, даже если терминал-скип когда-то не сработает).
## 4. Объём (Scope)
**В объёме:**
- Точечная правка `src/reconciler.py`: терминал-скип на пути F-1 + проброс `state_uuid` в `_note_unblock` из F-1.
- Сохранение/корректное инкрементирование наблюдаемости ORCH-068 (`skipped_terminal_total`, `deduped_total`, `unblocked_total`).
- Unit-тесты, покрывающие AC-1…AC-5.
- Обновление документации (`docs/architecture/README.md` блок Reconciler, `CHANGELOG.md`).
**Вне объёма (Не-цели):**
- НЕ ломать легитимный replay реально застрявшей задачи (когда реконсиляция её ДЕЙСТВИТЕЛЬНО двигает — уведомление полезно).
- НЕ трогать пайплайн / статусы enduro-trails.
- НЕ отключать `reconcile_notify_unblock` глобально (потеряем полезные алерты) — подавление **точечное**, только для терминальных.
- НЕ менять `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схему БД, контракты `advance_stage` / `advance_if_gate_passed`.
- НЕ менять поведение F-2 (там ORCH-068 уже корректен) сверх необходимого переиспользования хелперов.
## 5. Заинтересованные лица
- **Owner / Слава** — наблюдатель Telegram-карточек и алертов; страдающая сторона (шум).
- **enduro-trails** — проект, чьи терминальные задачи генерируют ложные алерты; пайплайн не должен быть затронут.
- **orchestrator (self-hosting)** — терминал-детект должен корректно работать и для self (разные наборы Plane-статусов).
## 6. Риски и ограничения
- **R1 (грабли мультипроектности).** enduro-trails и orchestrator — разные проекты с разными наборами Plane-статусов. Терминал-детект ОБЯЗАН работать для обоих: первичный дискриминатор — группа статуса Plane (`completed`/`cancelled`, project-independent), fallback — логические ключи `done`/`cancelled` (как в существующем `_is_terminal_state`, стр.338344).
- **R2 (наблюдаемость).** Нельзя сломать счётчики ORCH-068. При скипе терминальной задачи в F-1 — инкрементировать `skipped_terminal_total` (единая семантика с F-2). `deduped_total`/`unblocked_total` — без регрессии.
- **R3 (never-raise).** Тик реконсилятора обязан оставаться never-raise (сеть Plane может быть недоступна). Сбой терминал-проверки → консервативное поведение (как Guard 2: при ошибке скорее НЕ слать, чем слать ложно; но НЕ ценой подавления легитимного unblock — см. AC-4).
- **R4 (доп. сетевой вызов).** F-1 для проброса `state_uuid` и терминал-детекта должен знать текущий Plane-статус issue. Guard 2 (`_is_blocked_or_needs_input`) уже делает `fetch_issue_state`. Желательно переиспользовать один fetch, не удваивая обращения к Plane API на тик (производительность горячего цикла).
- **R5 (ложно-отрицательный риск).** Слишком агрессивное подавление может задушить полезный алерт о реально застрявшей задаче → обязателен регресс-тест AC-4.
## 7. Метрика успеха
- В Telegram больше нет периодического «ET-002 done разблокирована»; `skipped_terminal_total` растёт (наблюдаемо в `GET /queue`).
- `pytest tests/ -q` зелёный; новые тесты AC-1…AC-5 проходят.

View File

@@ -0,0 +1,68 @@
# 02-TRZ — ORCH-086: терминал-скип и dedup на пути F-1 реконсилятора
> Техническое задание. Архитектурное решение (КАК именно) — за архитектором (ADR). Здесь — ЧТО должно измениться и инварианты.
## 1. Задействованные модули `src/`
- **`src/reconciler.py`** — основной (и, как ожидается, единственный) изменяемый модуль:
- `Reconciler._reconcile_gate_task` (стр.180228) — путь F-1, где находится баг.
- `Reconciler._note_unblock` (стр.444477) — точка отправки уведомления + dedup-guard.
- `Reconciler._is_terminal_state` (стр.327344) — существующий терминал-детект (сейчас зовётся только из F-2); переиспользуется в F-1.
- `Reconciler._is_blocked_or_needs_input` (стр.230288) — уже делает `fetch_issue_state`; желательно переиспользовать его результат, чтобы не удваивать сетевой вызов.
- **Возможно затрагиваемые (read-only переиспользование, без изменения контракта):** `src/plane_sync.py` (`fetch_issue_state`, `get_project_states`, `get_project_state_groups`), `src/projects.py` (`get_project_by_repo`). Изменять их не требуется.
- **НЕ затрагиваются:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), `src/stage_engine.py` (`advance_stage`/`advance_if_gate_passed`), `src/db.py` (схема), `src/config.py` (новые флаги не вводятся).
## 2. Требуемые изменения (функциональные)
### TR-1 (G2): терминал-скип на пути F-1
В `_reconcile_gate_task` ДО вызова `_note_unblock` (а лучше — до/вместо доведения терминальной задачи до `advance_if_gate_passed`) добавить проверку: **является ли задача терминальной**.
- Терминальность определяется тем же способом, что и в F-2 (`_is_terminal_state`): первичный дискриминатор — **группа статуса Plane** issue ∈ `{completed, cancelled}`; fallback (группа недоступна) — логические ключи `done`/`cancelled` проекта. Это покрывает грабли R1 (enduro vs orchestrator).
- Дополнительно: терминальной считается и задача, чья **стадия в БД оркестратора**`{done, cancelled}` (на случай дрейфа Plane↔БД; `get_active_tasks_for_reconcile` уже отсекает `done`, но `cancelled` — нет).
- Терминальная задача → **return без advance и без `_note_unblock`**; инкремент `self.skipped_terminal_total` (единая семантика с F-2, стр.363).
- Скип **безусловный** (как терминал-скип F-2 — без отдельного kill-switch). Это НЕ маскирует легитимный replay: реально застрявшая задача терминальной в Plane не бывает.
> **Где именно** ставить проверку (до `advance_if_gate_passed` или внутри/перед `_note_unblock`) — решает архитектор. Рекомендация: ставить как ранний guard в `_reconcile_gate_task` рядом с Guard 1/Guard 2 (чтобы терминальная задача даже не запускала `advance_if_gate_passed`/гейт). Если терминал-детект требует Plane-статус, он логично переиспользует fetch из Guard 2.
### TR-2 (G3): проброс `state_uuid` в `_note_unblock` из F-1
Вызов на стр.228 должен передавать `state_uuid` (текущий Plane-state issue), чтобы in-memory dedup-guard (`_unblock_dedup`, стр.459463) работал и на пути F-1:
```python
# было:
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
# должно (концептуально):
self._note_unblock(task.get("work_item_id") or str(task_id), stage, state_uuid)
```
- `state_uuid` — текущий uuid статуса issue в Plane (тот же, что используется для терминал-детекта TR-1).
- Если Plane недоступен и `state_uuid` достоверно получить нельзя → допустимо передать `None` (dedup деградирует в no-op, как сегодня), НО приоритетно сначала отрабатывает терминал-скип TR-1; never-raise сохраняется.
- **Сигнатуру `_note_unblock` не менять** (3-й параметр `state_uuid` уже опциональный, стр.445).
### TR-3: переиспользование сетевого вызова (R4, нефункц., желательно)
F-1 не должен делать > 1 обращения к Plane API на задачу за тик ради статуса. `_is_blocked_or_needs_input` уже вызывает `fetch_issue_state`. Архитектор решает форму переиспользования (например, вынести резолв `(project_states, groups, current_state_uuid)` в один helper, питающий Guard 2 + терминал-скип TR-1 + dedup TR-2). Допустимо и без рефакторинга, если число вызовов на тик не растёт значимо.
## 3. Контракты и инварианты (НЕ нарушать)
- **never-raise:** каждая единица работы F-1 изолирована (`_reconcile_gate_task` уже под `try/except` в `reconcile_gate_once`, стр.162168). Любая ошибка терминал-детекта/fetch → не падает тик; консервативное поведение (R3): при невозможности достоверно определить терминальность — НЕ слать ложно, но и не глушить легитимный (см. AC-4: легитимный unblock — это реальная смена стадии не-терминальной задачи; терминал-неопределённость к нему не относится).
- **silence-when-in-sync:** терминальная (= полностью синхронизированная) задача → тишина (инвариант ORCH-068 AC-1/AC-2, теперь и для F-1).
- **Легитимный unblock сохраняется:** не-терминальная реально застрявшая задача с зелёным гейтом по-прежнему `advance` + уведомление (AC-4).
- **Наблюдаемость ORCH-068:** `skipped_terminal_total` инкрементируется при терминал-скипе F-1; `deduped_total` — при подавлении повтора dedup'ом; `unblocked_total`/`last_unblocked` — только при реальной отправке. Снимок `status()` (стр.516528) и блок `reconcile` в `GET /queue` — без структурных изменений.
- **Условность мультипроекта:** терминал-детект работает и для enduro, и для orchestrator (по группе статуса + fallback). Пайплайн/статусы enduro не трогаются.
## 4. Изменения API
Нет. HTTP-эндпоинты не меняются. `GET /queue` блок `reconcile` сохраняет форму (значения счётчиков — наблюдаемое поведение).
## 5. Изменения схемы БД
Нет. Миграции нет. (Терминальность Plane резолвится онлайн, как в ORCH-068 / Guard 2 — Вариант A без колонки статуса в `tasks`.)
## 6. Новые/изменённые QG checks
Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются.
## 7. Конфигурация
Новые флаги НЕ вводятся. Терминал-скип безусловен (как у F-2). Существующие `reconcile_enabled`, `reconcile_notify_unblock`, `reconcile_skip_blocked_enabled`, `reconcile_plane_enabled` — без изменений семантики. (`reconcile_skip_blocked_enabled` гейтит ТОЛЬКО Guard 2; терминал-скип TR-1 ему НЕ подчиняется.)
## 8. Артефакты pipeline, подлежащие обновлению (документация = golden source)
- `docs/architecture/README.md` — раздел «Reconciler … ORCH-068»: дописать, что терминал-исключение и dedup теперь покрывают и F-1 (gate-side), не только F-2.
- `CHANGELOG.md` — запись `fix:` про ORCH-086.
- `docs/work-items/ORCH-086/06-adr/ADR-NNN-*.md` — ADR (создаёт архитектор).
- (Опционально) краткая ссылка в ADR ORCH-068, что F-1-пробел закрыт ORCH-086.
## 9. Готовность к development (Definition of Ready)
- G1 подтверждён по prod-логам/БД (точная стадия ET-002 и путь срабатывания задокументированы в ADR/12-review).
- Тест-план `04-test-plan.yaml` реализован в `tests/test_reconciler.py`.
- `pytest tests/ -q` зелёный.

View File

@@ -0,0 +1,32 @@
# 03-Acceptance Criteria — ORCH-086
Каждый критерий формулирует чёткое условие PASS/FAIL. Проверяется автотестами (`tests/test_reconciler.py`) и код-ревью.
## AC-1 — ET-002 (терминальная) больше не генерирует «разблокирована»
**Дано:** F-1 (`reconcile_gate_once`) обрабатывает задачу enduro, чья стадия в БД оркестратора НЕ-`done` (дрейф), а текущий статус в Plane — терминальный (`Done`, группа `completed`); гейт стадии зелёный (для enduro — no-op `True`).
- **PASS:** `_note_unblock` НЕ вызывается → `send_telegram` НЕ вызывается ни при обычном тике, ни при первом проходе после старта (свежий процесс/чистый `_unblock_dedup`).
- **FAIL:** уведомление «… разблокирована (потерян webhook)» отправлено хотя бы раз.
## AC-2 — терминальные задачи (done/cancelled) не доходят до `_note_unblock`
**Дано:** задача терминальна в Plane (группа `completed` или `cancelled`) ИЛИ её стадия в БД ∈ `{done, cancelled}`.
- **PASS:** F-1 делает ранний скип (нет `advance` / нет `_note_unblock`); `skipped_terminal_total` увеличен на 1 на каждую такую задачу за тик.
- **FAIL:** терминальная задача доходит до `advance_if_gate_passed``_note_unblock`, либо `skipped_terminal_total` не растёт.
- **Грабли (R1):** условие должно срабатывать для ОБОИХ проектов — enduro (терминал по группе `completed`/`cancelled`, либо fallback-ключ `done`/`cancelled`) и orchestrator (свой набор статусов). Тест покрывает оба пути терминал-детекта: (а) по группе, (б) fallback по логическому ключу при пустых `groups`.
## AC-3 — `_note_unblock` на всех путях получает `state_uuid` → dedup покрывает все вызовы
**Дано:** легитимный unblock реально застрявшей НЕ-терминальной задачи на пути F-1 (гейт зелёный, стадия сменилась).
- **PASS:** `_note_unblock` вызван с непустым `state_uuid`; повторный вызов для того же `issue_id`+`state_uuid` (например на следующем тике до фактической смены статуса) подавляется dedup-guard'ом → `deduped_total` растёт, второго `send_telegram` нет.
- **FAIL:** F-1 зовёт `_note_unblock` без `state_uuid` (2 аргумента) → dedup не работает → повторные уведомления.
## AC-4 — легитимный unblock реально застрявшей задачи ПО-ПРЕЖНЕМУ уведомляет (анти-регресс)
**Дано:** НЕ-терминальная задача (Plane-статус рабочий, не `done`/`cancelled`/`blocked`/`needs_input`), реально застрявшая (прошла grace, нет active-job), гейт зелёный → F-1 её продвигает (`result.advanced=True`).
- **PASS:** `_note_unblock` вызван ОДИН раз; при `reconcile_notify_unblock=True` отправлен ровно один Telegram; `unblocked_total` += 1.
- **FAIL:** уведомление подавлено (полезный алерт задушен) ИЛИ отправлено более одного раза за одну смену стадии.
## AC-5 — pytest зелёный; never-raise в тике сохранён
- **PASS:** `pytest tests/ -q` зелёный; при исключении внутри терминал-детекта/`fetch_issue_state`/`_reconcile_gate_task` тик НЕ падает (изоляция per-task), и ложное уведомление при ошибке НЕ отправляется (консервативно).
- **FAIL:** падение тика, незелёный pytest, либо исключение терминал-детекта приводит к ложной отправке.
## AC-6 — без регрессий смежного поведения (контрактный)
- **PASS:** F-2 (plane-side) терминал-скип/dedup/счётчики работают как в ORCH-068; `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue` — не изменены; новые config-флаги не введены; `reconcile_skip_blocked_enabled` по-прежнему гейтит только Guard 2 (терминал-скип ему не подчинён). Документация (`README.md`, `CHANGELOG.md`) обновлена в том же PR.
- **FAIL:** любое из перечисленного нарушено.

View File

@@ -0,0 +1,110 @@
work_item: ORCH-086
description: >
Терминал-скип и проброс state_uuid на пути F-1 реконсилятора.
Тесты добавляются в tests/test_reconciler.py (рядом с существующими TC-01..TC-21),
переиспользуя фикстуры fresh_db / silence_side_effects / _green_ci /
plane_state_not_blocked и спай send_telegram. Все тесты — pytest, оффлайн
(Plane/Telegram мокаются), детерминированные.
tests:
- id: TC-86-01
type: unit
description: >
AC-1 — задача enduro НЕ-done в БД, но терминальная в Plane (group=completed),
гейт зелёный: F-1 НЕ вызывает _note_unblock и НЕ шлёт Telegram (ни при тике,
ни на первом проходе свежего Reconciler).
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-02
type: unit
description: >
AC-2 — терминал-скип инкрементирует skipped_terminal_total и НЕ вызывает
advance_if_gate_passed для терминальной задачи (advance_stage-спай не дёрнут).
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-03
type: unit
description: >
AC-2/R1 — терминал-детект по ГРУППЕ статуса Plane (completed/cancelled)
срабатывает независимо от проекта (enduro и orchestrator): задача в группе
cancelled тоже скипается.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-04
type: unit
description: >
AC-2/R1 — fallback терминал-детекта при пустых groups: терминальность по
логическому ключу done/cancelled проекта. Пустой groups + state_uuid ==
states['done'] -> скип.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-05
type: unit
description: >
AC-2 — терминальность по стадии БД оркестратора: задача со stage='cancelled'
(не отсекается get_active_tasks_for_reconcile, которое фильтрует только 'done')
скипается, не доходит до _note_unblock.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-06
type: unit
description: >
AC-3 — F-1 вызывает _note_unblock С непустым state_uuid (3 аргумента) на
легитимном unblock; проверяется, что dedup сохраняет ключ issue_id->state_uuid.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-07
type: unit
description: >
AC-3 — повторный F-1-тик для того же issue+state_uuid подавляется dedup-guard:
deduped_total += 1, второго send_telegram нет.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-08
type: unit
description: >
AC-4 (анти-регресс) — НЕ-терминальная реально застрявшая задача (рабочий
Plane-статус, прошла grace, нет active-job, гейт зелёный) ПО-ПРЕЖНЕМУ
продвигается и шлёт РОВНО один Telegram; unblocked_total += 1.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-09
type: unit
description: >
AC-5 — never-raise: исключение в терминал-детекте / fetch_issue_state не
роняет тик (reconcile_gate_once завершается) и НЕ приводит к ложной отправке
Telegram (консервативно: при неопределённости терминальности не уведомляем).
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-10
type: unit
description: >
AC-6 — регресс F-2: существующие TC F-2 (терминал-скип/dedup/счётчики
ORCH-068) остаются зелёными; форма status()/GET-queue не изменилась
(skipped_terminal_total, deduped_total, unblocked_total присутствуют).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-86-11
type: unit
description: >
AC-6 — reconcile_skip_blocked_enabled=False (escape hatch Guard 2) НЕ
отключает терминал-скип TR-1: терминальная задача всё равно скипается.
module: tests/test_reconciler.py
expected: PASS
- id: TC-86-12
type: unit
description: >
Полный прогон регрессии пакета reconciler: pytest tests/test_reconciler.py
tests/test_reconciler_plane.py tests/test_config.py -q зелёный.
module: tests/test_reconciler.py
expected: PASS

View File

@@ -0,0 +1,197 @@
# ADR-001: Терминал-скип и `state_uuid`-dedup на пути F-1 реконсилятора (одиночный fetch)
## Статус
Accepted
Связано: продолжение **ORCH-068** (терминал-исключение + dedup для F-2), наследует контракты
**ORCH-053** (`adr-0007-reconciler.md`), **ORCH-060** (Guard 1/Guard 2), **ORCH-066** (статусная
модель Plane). Не вводит сквозного решения — точечный фикс существующего компонента
`src/reconciler.py`; глобальный `adr-00NN` НЕ заводится (см. §«Область и масштаб»).
## Контекст
В Telegram периодически (особенно сразу после рестарта орка) прилетает ложное
`🔧 reconciler: ET-002 done разблокирована (потерян webhook)`. Задача `ET-002`
(enduro-trails) давно завершена; реально ничего не разблокируется — это шум, вводящий
наблюдателя в заблуждение.
ORCH-068 закрыл аналогичный livelock **только на F-2 (plane-side)** двумя механизмами:
1. `_is_terminal_state(state_uuid, states, groups)` — терминал-исключение по **группе статуса
Plane** (`completed`/`cancelled`, project-independent) с fallback на логические ключи
`done`/`cancelled`. Вызывается **только** из `_reconcile_plane_issue` (F-2, `reconciler.py:362`).
2. In-memory dedup-guard `_unblock_dedup` (`issue_id → state_uuid`) внутри `_note_unblock`
(`reconciler.py:459`), активный **только когда `state_uuid is not None`**.
Оба механизма **не покрывают путь F-1 (gate-side)**. Код-аудит (golden source — текущий
`src/reconciler.py`) подтверждает две независимые причины:
- **Причина A — dedup не срабатывает.** Вызов F-1 (`_reconcile_gate_task`, `reconciler.py:228`)
передаёт `_note_unblock(work_item_id, stage)`**только 2 аргумента, без `state_uuid`**. Ветка
dedup (`reconciler.py:459463`) пропускается → уведомление шлётся на каждом релевантном тике, а
после рестарта `_unblock_dedup` пуст → первый проход снова шлёт.
- **Причина B — нет терминал-скипа.** Единственный «терминал-фильтр» F-1 —
`get_active_tasks_for_reconcile()` (`db.py`, `WHERE stage != 'done'`), который смотрит **только
на стадию задачи в БД орка** и не знает о статусе issue в Plane. Для enduro (не self-hosting)
условные гейты (`check_staging_status`/`check_deploy_status`/merge-gate/…) — no-op `(True, …)`
(условность ORCH-35/43/58/71). Поэтому задача, чья стадия в БД орка ∈ не-`done` (дрейф), но в
Plane уже `Done` (группа `completed`), проходит фильтр → `advance_if_gate_passed` находит гейт
зелёным (no-op) → `result.advanced=True` (`reconciler.py:227`) → доходит до `_note_unblock`.
Guard 2 (`_is_blocked_or_needs_input`) её не спасает: его `skip_set` = `{blocked, needs_input,
extra_waits}` и **не содержит `done`/`cancelled`**.
> **G1 (открытый вопрос BRD):** точная стадия `ET-002` в БД орка в момент срабатывания подлежит
> подтверждению в development по prod-логам/БД. Настоящее решение **робастно независимо** от точной
> стадии: терминальность определяется по группе статуса Plane (как F-2), а не по строковому
> совпадению стадии. Документирование точной стадии — в `12-review.md` (DoR TRZ §9).
## Решение
Распространить **оба** механизма ORCH-068 на путь F-1, переиспользовав один сетевой вызов на
задачу за тик. Все изменения локализованы в `src/reconciler.py` (`_reconcile_gate_task` + один
новый helper). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/
`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue`**не меняются**. Новых
config-флагов нет.
### D1 — Одиночный резолв Plane-статуса задачи (TR-3, R4)
Ввести приватный helper, например:
```python
def _resolve_issue_status(self, task: dict) -> tuple[dict, dict, str | None]:
"""One networked resolve per task per tick: (states, groups, current_state_uuid).
never-raise; on any failure / unresolved project / missing state ->
(states_or_{}, groups_or_{}, None). The single fetch feeds the terminal-skip
(D2), Guard 2 (D3) and the state_uuid handed to _note_unblock (D4).
"""
```
Внутри — **один** `fetch_issue_state(issue_id, pid)` плюс кэшируемые (ORCH-068 TTL)
`get_project_states(pid)` / `get_project_state_groups(pid)`. Это устраняет удвоение сетевого вызова
(сегодня `_is_blocked_or_needs_input` делает свой `fetch_issue_state` и **выбрасывает** uuid).
### D2 — Терминал-скип на F-1 (TR-1, G2), безусловный
В `_reconcile_gate_task`, **после** дешёвых локальных гардов (active-job, grace, Guard 1
retry-count — все без сети) и **до** Guard 2 / `advance_if_gate_passed`, вставить ранний guard:
```python
states, groups, state_uuid = self._resolve_issue_status(task)
# DB-side drift: cancelled is not filtered by get_active_tasks_for_reconcile (only done is).
if stage in ("done", "cancelled") or self._is_terminal_state(state_uuid, states, groups):
self.skipped_terminal_total += 1
return
```
- Терминальность — тот же `_is_terminal_state` (переиспользование, **не** дублирование): первичный
дискриминатор — группа Plane ∈ `{completed, cancelled}`; fallback при пустых `groups` — логические
ключи `done`/`cancelled`. Покрывает R1 (enduro и orchestrator с разными наборами статусов).
- Дополнительно терминальной считается задача, чья **стадия в БД**`{done, cancelled}` (дрейф
Plane↔БД; `cancelled` сейчас не отсекается на уровне выборки).
- **Безусловный** — не подчинён `reconcile_skip_blocked_enabled` (тот гейтит **только** Guard 2).
Это не маскирует легитимный replay: реально застрявшая задача терминальной в Plane не бывает.
- Инкремент `skipped_terminal_total` — единая семантика с F-2 (`reconciler.py:363`).
### D3 — Guard 2 переиспользует резолв (рефактор, без смены контракта)
`_is_blocked_or_needs_input` принимает уже резолвнутые `(states, state_uuid)` вместо собственного
`fetch_issue_state`. Поведение и kill-switch `reconcile_skip_blocked_enabled` сохранены 1:1
(флаг off → ранний `return False` без использования резолва; ошибка/`state_uuid is None`
консервативный `return True` — skip). Допустима форма с дефолтными параметрами для обратной
совместимости вызова, но единственный продакшен-вызов — из `_reconcile_gate_task` с общим резолвом.
### D4 — Проброс `state_uuid` в `_note_unblock` (TR-2, G3)
Вызов на `reconciler.py:228` передаёт третий аргумент:
```python
self._note_unblock(task.get("work_item_id") or str(task_id), stage, state_uuid)
```
`state_uuid` — тот же, что резолвнут в D1. Сигнатура `_note_unblock` **не меняется** (3-й параметр
уже опциональный). Теперь in-memory dedup (`reconciler.py:459463`) работает и на F-1:
повторный вызов для того же `issue_id`+`state_uuid` (следующий тик до фактической смены статуса) →
`deduped_total += 1`, второго Telegram нет. Если Plane недоступен и `state_uuid` достоверно
получить нельзя → `None` (dedup деградирует в no-op, как сегодня) — но первым отрабатывает
терминал-скип D2 и/или консервативный Guard 2 D3.
### Порядок гардов в `_reconcile_gate_task` (итог)
```
analysis-skip → qg-none-skip → active-job-skip → grace-skip
→ Guard 1 (retry-count, local SQL, no network)
→ [D1] resolve (states, groups, state_uuid) # единственный сетевой fetch
→ [D2] terminal-skip (unconditional) # skipped_terminal_total++
→ Guard 2 (_is_blocked_or_needs_input, reuse) # gated by reconcile_skip_blocked_enabled
→ Guard 3 (task_deps)
→ advance_if_gate_passed → [D4] _note_unblock(..., state_uuid)
```
Терминал-скип **до** Guard 2, чтобы терминальные задачи корректно увеличивали
`skipped_terminal_total` (а не молчаливо проглатывались консервативным Guard 2). Резолв D1 — после
дешёвых локальных гардов, чтобы busy/молодые задачи не порождали сетевых вызовов.
### Семантика ошибок (never-raise, R3, AC-5)
- `_resolve_issue_status` never-raise → при сбое `state_uuid=None`, `groups={}`.
- `state_uuid=None``_is_terminal_state` возвращает `False` (нельзя подтвердить терминал по
Plane), но DB-side `stage ∈ {done, cancelled}` всё ещё ловит дрейф.
- При дефолтной конфигурации (`reconcile_skip_blocked_enabled=True`) недостижимый Plane →
Guard 2 консервативно `True`**skip**, ложное уведомление не уходит (AC-5).
- Любое исключение в резолве/детекте изолировано `try/except` уровня
`reconcile_gate_once` (`reconciler.py:162168`) → тик не падает.
## Последствия
### Плюсы
- Устраняется периодический ложный «ET-002 … разблокирована»; наблюдаемо ростом
`skipped_terminal_total` в `GET /queue` (метрика успеха BRD §7).
- Робастно для обоих проектов: первичный дискриминатор — группа статуса Plane (R1).
- Один сетевой вызов на задачу за тик (не растёт нагрузка горячего цикла, R4) — резолв заодно
питает Guard 2, ранее делавший отдельный fetch.
- Dedup-страховка теперь покрывает F-1: даже если терминал-скип однажды не сработает, повтор
подавляется (`deduped_total`).
- Симметрия F-1 ↔ F-2: единая семантика терминал-исключения и счётчиков; легче сопровождать.
- Нулевой контрактный след: ни стадий, ни QG, ни схемы БД, ни новых флагов, ни смены сигнатур.
### Минусы / ограничения
- **Доп. fetch при `reconcile_skip_blocked_enabled=False`.** Раньше при выключенном Guard 2 F-1 не
ходил в Plane вовсе. Теперь терминал-скип (безусловный, по требованию TR-1) делает резолв даже
при выключенном escape-hatch. Вызов never-raise и быстро деградирует в `None`, но это новая
сетевая операция в этом режиме. **Принято** как цена корректности (TRZ §7 явно: терминал-скип не
подчинён этому флагу).
- **Угол «escape-hatch off + Plane недоступен».** При `reconcile_skip_blocked_enabled=False` И
недостижимом Plane Guard 2 не защищает, терминал-скип не подтверждает терминал (`state_uuid=None`),
и не-`cancelled` дрейф-задача может быть продвинута + уведомлена с `state_uuid=None`. Это **тот же
деградированный режим, что и сегодня** (новой гарантии под выключенный escape-hatch не даётся;
и регрессии нет). Дефолтная конфигурация полностью консервативна.
- Терминал-скип считает `skipped_terminal_total` только для задач, прошедших active-job/grace гарды
(как и F-2 считает только среди actionable issue). Это намеренно — счётчик отражает «дошло бы до
ложного unblock, но подавлено», а не «всего терминальных в системе».
### Анти-регресс (AC-4)
Легитимный unblock реально застрявшей **не-терминальной** задачи (рабочий Plane-статус, гейт
зелёный, стадия реально сменилась) по-прежнему уведомляет ровно один раз с непустым `state_uuid`
(`unblocked_total += 1`). Терминал-скип к нему не применяется (такая задача не терминальна), Guard 2
её не глушит (статус рабочий). F-2 не затронут.
## Область и масштаб (почему нет глобального ADR)
Изменение **не сквозное**: не вводит новой стадии, QG, компонента или среды; это точечное
расширение уже существующего поведения реконсилятора (ORCH-053/`adr-0007`, доработка ORCH-068).
По конвенции глобальные `adr-00NN` заводятся для сквозных решений — здесь достаточно per-work-item
ADR + обновления раздела «Reconciler» в `docs/architecture/README.md` (golden source) и
`CHANGELOG.md`. Лейбл `arch:major-change` НЕ выставляется.
## Альтернативы (отклонены)
- **Глобально выключить `reconcile_notify_unblock`** — теряем полезные алерты о реально застрявших
задачах (BRD не-цель). Подавление должно быть точечным (только терминальные).
- **Сужать выборку `get_active_tasks_for_reconcile` по статусу Plane** — потребовало бы сети в SQL-
выборке горячего цикла очереди всех проектов (анти-паттерн ORCH-026: claim/sweep offline-устойчивы)
и/или колонку статуса в `tasks` (миграция БД). Отклонено: терминальность резолвится онлайн
per-task (Вариант A, как ORCH-068 / Guard 2).
- **Только проброс `state_uuid` (D4) без терминал-скипа (D2)** — dedup подавил бы повтор в пределах
жизни процесса, но после рестарта (`_unblock_dedup` пуст) первый проход снова бы слал ложное
уведомление (ровно симптом BRD «особенно после рестарта»). Нужны оба механизма.
- **Терминал-детект по строке стадии** — хрупко при дрейфе Plane↔БД и мультипроектности (R1).
Группа статуса Plane — устойчивый дискриминатор.

View File

@@ -0,0 +1,21 @@
# 10-Tech Risks — ORCH-086
Технические риски выбранного решения (ADR-001). Бизнес-риски R1R5 — в `01-brd.md`; здесь —
реализационные риски конкретного дизайна (одиночный fetch + терминал-скип на F-1).
| # | Риск | Вероятность / Влияние | Митигация (как проверяется) |
|---|------|----------------------|------------------------------|
| TR-A | **Регрессия Guard 2 при рефакторе.** Перевод `_is_blocked_or_needs_input` на внешний резолв `(states, state_uuid)` может незаметно изменить семантику kill-switch `reconcile_skip_blocked_enabled` или консервативный fallback (`return True` при ошибке). | Низкая / Высокая | Поведение флага и fallback сохранить 1:1; контрактный тест AC-6 + регресс-тест Guard 2 (flag off → `False`; ошибка/`state_uuid=None``True`). |
| TR-B | **Угол «escape-hatch off + Plane недоступен».** При `reconcile_skip_blocked_enabled=False` и недостижимом Plane не-`cancelled` дрейф-задача может быть продвинута + ложно уведомлена (`state_uuid=None`). | Низкая / Средняя | Принятый деградированный режим (== сегодняшнее поведение, без новой гарантии). Дефолт (`flag=True`) полностью консервативен — основной тест AC-5 идёт под дефолтом. Задокументировано в ADR «Минусы». |
| TR-C | **Двойной сетевой вызов на тик.** Если резолв D1 и Guard 2 случайно оба сделают `fetch_issue_state`, нагрузка горячего цикла вырастет (R4). | Средняя / Средняя | Ровно один `fetch_issue_state` на задачу за тик; тест считает число вызовов `fetch_issue_state` (mock call_count == 1) на пути F-1. |
| TR-D | **Счётчик `skipped_terminal_total` расходится с семантикой F-2.** Двойной инкремент или инкремент не на ту задачу ломает наблюдаемость ORCH-068 (R2). | Низкая / Средняя | Инкремент ровно один раз на терминальную задачу за тик, перед `return`; тест AC-2 проверяет `+1` на задачу и отсутствие `advance`/`_note_unblock`. |
| TR-E | **Терминал-детект ломается на пустых `groups` (fallback).** При недоступности `get_project_state_groups` (пустой dict) `_is_terminal_state` должен корректно падать на логические ключи `done`/`cancelled`, иначе терминал enduro не распознается. | Низкая / Высокая | Переиспользуется существующий `_is_terminal_state` (уже покрыт для F-2); тест AC-2 покрывает обе ветви — (а) по группе, (б) fallback по ключу при пустых `groups`. |
| TR-F | **Порядок гардов.** Если терминал-скип поставить после Guard 2, терминальная задача молча проглатывается консервативным Guard 2 и `skipped_terminal_total` не растёт (теряем метрику успеха). | Низкая / Средняя | Терминал-скип строго ДО Guard 2 (ADR порядок гардов); тест проверяет инкремент счётчика именно при терминале. |
| TR-G | **never-raise в новом helper.** Исключение в `_resolve_issue_status`/`_is_terminal_state` не должно ронять тик и не должно приводить к ложной отправке. | Низкая / Высокая | helper под `try/except``(…, None)`; тик уже изолирован `reconcile_gate_once` (`reconciler.py:162`). Тест AC-5: исключение в fetch → тик жив, `send_telegram` не вызван. |
| TR-H | **Анти-регресс легитимного unblock (AC-4).** Слишком широкий терминал/skip-set может задушить полезный алерт о реально застрявшей не-терминальной задаче. | Низкая / Высокая | Терминал-детект строго по `{completed, cancelled}` (+ DB `done`/`cancelled`); регресс-тест AC-4 — не-терминальная задача с зелёным гейтом уведомляет ровно один раз. |
## Зависимости / предпосылки
- `fetch_issue_state`, `get_project_states`, `get_project_state_groups`, `get_project_by_repo`
переиспользуются read-only, без изменения контракта (TRZ §1).
- G1 (точная стадия `ET-002`) подтверждается в development по prod-логам/БД и фиксируется в
`12-review.md` (DoR TRZ §9). Решение робастно независимо от исхода G1.

View File

@@ -0,0 +1,51 @@
---
type: review
work_item_id: ORCH-086
verdict: APPROVED
version: 1
---
# Review ORCH-086
## Summary
Терминал-скип и `state_uuid`-dedup распространены на путь F-1 реконсилятора, закрывая F-1-пробел
ORCH-068 (ложное «ET-002 done разблокирована (потерян webhook)»). Изменение полностью локализовано
в `src/reconciler.py` (новый `_resolve_issue_status` + врезка ранних гардов в `_reconcile_gate_task`
+ переиспользование резолва в `_is_blocked_or_needs_input` через опц. аргументы с `_UNSET`-sentinel
для обратной совместимости). Реализация 1:1 соответствует ТЗ (TR-1/TR-2/TR-3) и ADR-001 (D1D4).
`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/
`_note_unblock`, форма `status()`/`GET /queue`, config-флаги — без изменений. Контракт never-raise
сохранён на всех новых путях. Полный прогон `pytest tests/ -q` зелёный — 1069 passed.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- (нет)
## Документация
Обновлена в том же PR, соответствует требованию «golden source наравне с кодом» (CLAUDE.md §2,
TRZ §8):
- `docs/architecture/README.md` — раздел Reconciler F-1 дополнен блоком ORCH-086 (терминал-скип +
dedup на F-1, единый fetch на тик, безусловность относительно `reconcile_skip_blocked_enabled`).
- `CHANGELOG.md` — запись `fix:` ORCH-086 с описанием корня (причины A/B) и фикса (D1D4).
- `docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md` — присутствует,
Accepted, описывает решение, порядок гардов, семантику ошибок и отклонённые альтернативы.
- API не менялось → обновление таблицы API не требуется. Per-work-item ADR достаточно (точечный фикс
существующего компонента, не сквозное решение — обосновано в §«Область и масштаб»).
## Контроль качества
- Тесты содержательные, не тривиальные: TC-86-01..09/11 (`tests/test_reconciler.py`) покрывают
терминал по группе `completed`/`cancelled`, fallback по логическому ключу при пустых `groups`,
DB-side `cancelled` без обращения к Plane, проброс/dedup `state_uuid`, анти-регресс легитимного
unblock, never-raise без ложного уведомления, независимость терминал-скипа от Guard-2-флага;
TC-86-10 (`tests/test_reconciler_plane.py`) — неизменность формы `status()`; TC-86-12 — зелёный
регресс-прогон. Сопутствующая правка `tests/test_orch026_task_deps.py` корректно адаптирует мок
Guard 2 под новую сигнатуру и держит резолв offline.
- `task.get("plane_id") or task.get("plane_issue_id")` в `_resolve_issue_status` — дословный перенос
ранее протестированной логики Guard 2 (ORCH-060), регрессии нет.

View File

@@ -0,0 +1,67 @@
---
type: test-report
work_item_id: ORCH-086
result: PASS
---
# Test Report — ORCH-086
Терминал-скип и проброс/dedup `state_uuid` на пути F-1 реконсилятора (закрытие F-1-пробела
ORCH-068: ложное «ET-002 done разблокирована (потерян webhook)»).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Repo / ветка: orchestrator @ `feature/ORCH-086-orch-86-reconciler-telegram-et` (worktree)
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK
- Дата: 2026-06-09
## Предусловия
- Review-вердикт (`12-review.md`): **APPROVED** (P0/P1/P2 — нет).
## Результаты
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-86-01 | AC-1 — терминальная enduro-задача (group=completed), зелёный гейт: нет `_note_unblock`/Telegram | `test_tc86_01_terminal_in_plane_not_unblocked` | PASS |
| TC-86-02 | AC-2 — терминал-скип `++skipped_terminal_total`, нет `advance_if_gate_passed` | `test_tc86_02_terminal_skip_counter_no_advance` | PASS |
| TC-86-03 | AC-2/R1 — терминал по ГРУППЕ (cancelled), независимо от проекта | `test_tc86_03_terminal_by_group_cancelled` | PASS |
| TC-86-04 | AC-2/R1 — fallback по логическому ключу done/cancelled при пустых groups | `test_tc86_04_terminal_fallback_logical_key_empty_groups` | PASS |
| TC-86-05 | AC-2 — терминальность по стадии БД (`stage='cancelled'`) | `test_tc86_05_terminal_by_db_stage_cancelled` | PASS |
| TC-86-06 | AC-3 — легитимный unblock зовёт `_note_unblock` с непустым `state_uuid` | `test_tc86_06_legit_unblock_passes_state_uuid` | PASS |
| TC-86-07 | AC-3 — повторный тик для того же issue+state_uuid подавлен dedup (`++deduped_total`) | `test_tc86_07_repeat_tick_deduped` | PASS |
| TC-86-08 | AC-4 (анти-регресс) — реально застрявшая задача продвигается, ровно один Telegram, `++unblocked_total` | `test_tc86_08_legit_unblock_still_notifies` | PASS |
| TC-86-09 | AC-5 — never-raise: исключение в детекте не роняет тик и не шлёт ложного Telegram | `test_tc86_09_never_raise_no_false_notify` | PASS |
| TC-86-10 | AC-6 — форма `status()`/`GET /queue` неизменна (счётчики на месте) | `test_tc86_10_status_shape_unchanged` (test_reconciler_plane.py) | PASS |
| TC-86-11 | AC-6 — `reconcile_skip_blocked_enabled=False` НЕ отключает терминал-скип | `test_tc86_11_terminal_skip_independent_of_guard2_flag` | PASS |
| TC-86-12 | Полный регресс пакета reconciler/config зелёный | `pytest tests/test_reconciler.py tests/test_reconciler_plane.py tests/test_config.py` | PASS |
## Smoke test API (prod 8500)
- `GET /health``{"status":"ok","service":"orchestrator"}` — OK
- `GET /status` → 200, валидный JSON (`active_tasks` присутствует) — OK
- `GET /queue` → 200, блок `reconcile` присутствует (`enabled`, `unblocked_total`, `last_unblocked`, `interval`) — OK
## Вывод pytest
Полный прогон:
```
1069 passed, 1 warning in 26.16s
```
Целевой регресс-пакет (TC-86-12):
```
78 passed, 1 warning in 2.38s
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, не связан с задачей.)
## Покрытие критериев приёмки
- AC-1 — TC-86-01 ✓
- AC-2 — TC-86-02/03/04/05 ✓
- AC-3 — TC-86-06/07 ✓
- AC-4 — TC-86-08 ✓
- AC-5 — TC-86-09 + зелёный полный прогон ✓
- AC-6 — TC-86-10/11 + контракты (STAGE_TRANSITIONS/QG_CHECKS/схема БД/сигнатуры не тронуты) ✓
## Итог
**PASS** — все 12 тест-кейсов PASS, полный регресс `pytest tests/` зелёный (1069 passed),
smoke API OK. Задача готова к переходу на стадию `deploy-staging`.

View File

@@ -1077,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.

View File

@@ -442,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 = ""

View File

@@ -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"),
]

View File

@@ -57,6 +57,9 @@ def send_telegram(text: str, disable_notification: bool = False):
"text": text,
"parse_mode": "HTML",
"disable_notification": disable_notification,
# ORCH-080: suppress the Plane link-preview banner that Telegram
# would otherwise expand under every tracker card / notification.
"disable_web_page_preview": True,
},
timeout=5,
)
@@ -170,6 +173,8 @@ def edit_telegram(message_id: int, text: str) -> str:
"message_id": message_id,
"text": text,
"parse_mode": "HTML",
# ORCH-080: suppress the Plane link-preview banner (see send_telegram).
"disable_web_page_preview": True,
},
timeout=5,
)

View File

@@ -73,6 +73,11 @@ from . import task_deps
logger = logging.getLogger("orchestrator.reconciler")
# ORCH-086 (D3): sentinel distinguishing "caller did not pass a pre-resolved
# state_uuid" (Guard 2 self-resolves, backward-compatible 1-arg call) from an
# explicit ``None`` (Plane unreachable -> conservative skip).
_UNSET = object()
def _parse_grace_overrides(raw: str) -> dict[str, int]:
"""Parse ``reconcile_grace_overrides_json`` into {stage: seconds}.
@@ -183,6 +188,14 @@ class Reconciler:
# AC-16: analysis is a human gate -> owned by F-2, never F-1.
if stage == "analysis":
return
# ORCH-086 D2 (DB-side terminal drift): ``get_active_tasks_for_reconcile``
# filters ``stage != 'done'`` but NOT ``cancelled``. A task already
# terminal in the orchestrator DB is fully in sync by definition -> skip
# before any gate/network work, mirroring the F-2 terminal-skip counter
# (single semantics with ``_reconcile_plane_issue``). Local, no network.
if stage in ("done", "cancelled"):
self.skipped_terminal_total += 1
return
# created / done have no gate to evaluate.
if get_qg_for_stage(stage) is None:
return
@@ -201,9 +214,25 @@ class Reconciler:
# Deterministic, local SQL, no network — and checked FIRST (cheapest).
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
return
# ORCH-086 D1: single networked resolve per task per tick, AFTER the cheap
# local guards (so busy/young/escalated tasks never hit Plane). Feeds the
# Plane-side terminal-skip (D2), Guard 2 (D3) and the state_uuid handed to
# _note_unblock (D4) — no duplicate fetch.
states, groups, state_uuid = self._resolve_issue_status(task)
# ORCH-086 D2 (Plane-side terminal-skip), UNCONDITIONAL (not gated by
# reconcile_skip_blocked_enabled, which gates ONLY Guard 2). A task whose
# Plane status is terminal (group completed/cancelled, or the logical
# done/cancelled fallback) is fully in sync -> never a real unblock.
# Runs BEFORE Guard 2 so terminal tasks correctly bump skipped_terminal_total
# instead of being swallowed by Guard 2's conservative path. Closes the F-1
# gap of ORCH-068 (which only covered F-2); fixes the spurious
# "ET-002 ... разблокирована" notification.
if self._is_terminal_state(state_uuid, states, groups):
self.skipped_terminal_total += 1
return
# ORCH-060 Guard 2: respect an explicit human gate (Blocked / Needs Input).
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
if self._is_blocked_or_needs_input(task):
# Reuses the D1 resolve (ORCH-086 D3) so the tick makes a single fetch.
if self._is_blocked_or_needs_input(task, states, state_uuid):
return
# ORCH-026 Guard 3 (B-5): a task blocked by an unfinished declared
# dependency is legitimately waiting, NOT stuck -> F-1 must not advance it
@@ -225,9 +254,48 @@ class Reconciler:
task.get("branch") or "",
)
if result is not None and getattr(result, "advanced", False):
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
# ORCH-086 D4: pass state_uuid so the in-memory dedup guard covers F-1
# too (a repeat tick for the same issue+state is suppressed; survives
# the "first pass after restart" symptom together with the D2 skip).
self._note_unblock(
task.get("work_item_id") or str(task_id), stage, state_uuid
)
def _is_blocked_or_needs_input(self, task: dict) -> bool:
def _resolve_issue_status(
self, task: dict
) -> tuple[dict, dict, str | None]:
"""ORCH-086 D1: one networked resolve per task per tick.
Returns ``(states, groups, current_state_uuid)``. A single
``fetch_issue_state`` plus the cached (ORCH-068 TTL)
``get_project_states`` / ``get_project_state_groups``. The result feeds
the terminal-skip (D2), Guard 2 (D3) and the ``state_uuid`` handed to
``_note_unblock`` (D4), so the tick never fetches the same issue twice.
**never-raise.** On any failure / unresolved project / missing state ->
``({} or states, {} or groups, None)`` so callers apply their
conservative fallback (terminal-skip = not terminal; Guard 2 = skip).
"""
try:
proj = projects.get_project_by_repo(task.get("repo") or "")
if proj is None:
return {}, {}, None
pid = proj.plane_project_id
states = get_project_states(pid)
groups = get_project_state_groups(pid)
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
state_uuid = fetch_issue_state(issue_id, pid)
return states or {}, groups or {}, state_uuid
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(
f"reconciler D1: status resolve failed for task "
f"{task.get('id')}, treating as unresolved: {e}"
)
return {}, {}, None
def _is_blocked_or_needs_input(
self, task: dict, states: dict | None = None, state_uuid=_UNSET
) -> bool:
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
an active orchestrator wait that F-1 must not "revive"?
@@ -251,19 +319,22 @@ class Reconciler:
human-gated task re-introduces the bounce we are trying to kill. The
sub-flag ``reconcile_skip_blocked_enabled`` disables ONLY this networked
guard (escape hatch for a Plane outage); Guard 1 stays active.
**ORCH-086 D3:** the production caller (``_reconcile_gate_task``) passes
the already-resolved ``(states, state_uuid)`` from the single D1 fetch, so
the tick does not hit Plane twice. When ``state_uuid`` is left ``_UNSET``
(direct/legacy 1-arg call) Guard 2 self-resolves via ``_resolve_issue_status``
— behaviour identical to the pre-ORCH-086 code.
"""
if not settings.reconcile_skip_blocked_enabled:
return False
try:
proj = projects.get_project_by_repo(task.get("repo") or "")
if proj is None:
return True # cannot resolve the project -> conservative skip
pid = proj.plane_project_id
states = get_project_states(pid)
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
cur = fetch_issue_state(issue_id, pid)
if cur is None:
return True # Plane unreachable / no state -> conservative skip
if state_uuid is _UNSET:
# Backward-compatible self-resolve (direct callers / tests).
states, _groups, state_uuid = self._resolve_issue_status(task)
if not states or state_uuid is None:
return True # unresolved project / Plane unreachable -> conservative skip
cur = state_uuid
# ORCH-066 BR-13: active orchestrator waits, minus base working
# statuses so aliased (enduro) keys never widen the skip-set.
base_working = {

View File

@@ -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(

View File

@@ -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

View File

@@ -0,0 +1,159 @@
"""ORCH-080 — suppress Telegram link-preview in tracker/notify primitives.
Both low-level primitives ``send_telegram`` (POST /sendMessage) and
``edit_telegram`` (POST /editMessageText) must add
``"disable_web_page_preview": True`` to their JSON payload, so the Plane
"Modern project management" banner no longer expands under every tracker card /
notification. The clickable issue link must stay clickable -> ``parse_mode:
"HTML"`` is preserved in both payloads, and the never-raise / return contracts
are unchanged.
Network is isolated: ``src.notifications.httpx`` is patched; creds are stubbed.
Test ids TC-01..TC-06 from 04-test-plan.yaml.
"""
import os
import tempfile
from unittest.mock import MagicMock, patch
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_link_preview.db")
os.environ.setdefault("ORCH_DB_PATH", _test_db)
from src import notifications as N # noqa: E402
# conftest._no_telegram autouse-patches src.notifications.send_telegram to a
# no-op for every test (prod-leak guard). Capture the REAL implementation at
# import time (before any fixture runs) so these payload tests can exercise it.
_REAL_SEND = N.send_telegram
def _patch_tg_creds(monkeypatch):
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False)
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False)
def _ok_resp(message_id=42):
resp = MagicMock()
resp.json.return_value = {"ok": True, "result": {"message_id": message_id}}
return resp
# --------------------------------------------------------------------------- #
# TC-01 — send_telegram sets disable_web_page_preview: True
# --------------------------------------------------------------------------- #
def test_send_telegram_disables_link_preview(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
_REAL_SEND("hello")
payload = hx.post.call_args.kwargs["json"]
assert payload["disable_web_page_preview"] is True
# --------------------------------------------------------------------------- #
# TC-02 — edit_telegram sets disable_web_page_preview: True
# --------------------------------------------------------------------------- #
def test_edit_telegram_disables_link_preview(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
N.edit_telegram(1, "hello")
payload = hx.post.call_args.kwargs["json"]
assert payload["disable_web_page_preview"] is True
# --------------------------------------------------------------------------- #
# TC-03 — parse_mode HTML preserved in both payloads (clickable <a href>)
# --------------------------------------------------------------------------- #
def test_send_telegram_keeps_parse_mode_html(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
_REAL_SEND("hello")
assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML"
def test_edit_telegram_keeps_parse_mode_html(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
N.edit_telegram(1, "hello")
assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML"
# --------------------------------------------------------------------------- #
# TC-04 — send_telegram preserves existing fields + disable_notification arg
# --------------------------------------------------------------------------- #
def test_send_telegram_preserves_existing_fields(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
_REAL_SEND("body", disable_notification=True)
payload = hx.post.call_args.kwargs["json"]
assert payload["chat_id"] == "C"
assert payload["text"] == "body"
assert payload["parse_mode"] == "HTML"
assert payload["disable_notification"] is True
def test_send_telegram_disable_notification_default_false(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
_REAL_SEND("body")
assert hx.post.call_args.kwargs["json"]["disable_notification"] is False
def test_edit_telegram_preserves_existing_fields(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
N.edit_telegram(7, "body")
payload = hx.post.call_args.kwargs["json"]
assert payload["chat_id"] == "C"
assert payload["message_id"] == 7
assert payload["text"] == "body"
assert payload["parse_mode"] == "HTML"
# --------------------------------------------------------------------------- #
# TC-05 — return contracts unchanged
# --------------------------------------------------------------------------- #
def test_send_telegram_returns_message_id(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp(message_id=99)
assert _REAL_SEND("x") == 99
def test_send_telegram_returns_none_without_creds(monkeypatch):
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False)
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False)
assert _REAL_SEND("x") is None
def test_edit_telegram_returns_edit_ok(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _ok_resp()
assert N.edit_telegram(1, "x") == N.EDIT_OK
# --------------------------------------------------------------------------- #
# TC-06 — never-raise: httpx.post raising -> None / EDIT_FAILED
# --------------------------------------------------------------------------- #
def test_send_telegram_never_raises(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.side_effect = Exception("boom")
assert _REAL_SEND("x") is None
def test_edit_telegram_never_raises(monkeypatch):
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.side_effect = Exception("boom")
assert N.edit_telegram(1, "x") == N.EDIT_FAILED

View File

@@ -148,8 +148,12 @@ def test_reconciler_skip_helper_honours_block(monkeypatch):
monkeypatch.setattr(rec.settings, "reconcile_grace_default_s", 0, raising=False)
r = rec.Reconciler()
# Bypass Guard 2 (networked) so we isolate Guard 3.
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda task: False)
# Bypass Guard 2 (networked) so we isolate Guard 3. ORCH-086: the production
# call now passes the resolved (states, state_uuid), so accept extra args.
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda *a, **k: False)
# ORCH-086: the D1 resolve now runs before Guard 2 (for the terminal-skip) —
# keep it offline so this Guard-3 test stays deterministic.
monkeypatch.setattr(r, "_resolve_issue_status", lambda task: ({}, {}, None))
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}

View 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")

View 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"

View File

@@ -744,3 +744,233 @@ def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
assert _guard2(monkeypatch, aliased, "done-u") is False
# The explicit human gates still skip.
assert _guard2(monkeypatch, aliased, "blocked-u") is True
# ===========================================================================
# ORCH-086: terminal-skip + state_uuid dedup on the F-1 (gate-side) path.
# Closes the gap of ORCH-068 (which covered only F-2). The spurious
# "ET-002 ... разблокирована (потерян webhook)" notification for a task that is
# already terminal in Plane (but drifted in the orchestrator DB) is suppressed.
# ===========================================================================
def _plane_terminal(monkeypatch, *, state_uuid="done-uuid",
states=None, groups=None):
"""Make Plane report ``state_uuid`` as the issue's current state, with the
given {key->uuid} states and {uuid->group} groups maps."""
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
MagicMock(return_value=state_uuid))
monkeypatch.setattr(reconciler_mod, "get_project_states",
MagicMock(return_value=states if states is not None
else {"done": "done-uuid"}))
monkeypatch.setattr(reconciler_mod, "get_project_state_groups",
MagicMock(return_value=groups if groups is not None
else {"done-uuid": "completed"}))
# --- TC-86-01 (AC-1) -------------------------------------------------------
def test_tc86_01_terminal_in_plane_not_unblocked(monkeypatch):
"""enduro task NOT-done in the DB but terminal in Plane (group=completed),
green gate: F-1 must NOT call _note_unblock / send_telegram — neither on a
normal tick nor on the first pass of a fresh Reconciler (clean dedup)."""
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
note = MagicMock()
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
_plane_terminal(monkeypatch) # Plane says Done (group=completed)
task_id = _make_task("development", wi="ET-002", age_s=3600)
# Fresh Reconciler -> empty _unblock_dedup -> the "first pass after restart"
# symptom is exercised; the terminal-skip must fire regardless of dedup.
rec = Reconciler()
rec.reconcile_gate_once()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development" # never advanced
note.assert_not_called()
tg.assert_not_called()
assert rec.unblocked_total == 0
assert rec.skipped_terminal_total >= 1
# --- TC-86-02 (AC-2) -------------------------------------------------------
def test_tc86_02_terminal_skip_counter_no_advance(monkeypatch):
"""Terminal-skip bumps skipped_terminal_total and never reaches
advance_if_gate_passed."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(monkeypatch)
_make_task("development", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
# --- TC-86-03 (AC-2 / R1) --------------------------------------------------
def test_tc86_03_terminal_by_group_cancelled(monkeypatch):
"""Terminal detection by Plane state GROUP works for cancelled too, and is
project-independent (group discriminator, not a per-project key)."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(
monkeypatch, state_uuid="cancel-uuid",
states={"done": "done-uuid", "cancelled": "cancel-uuid"},
groups={"cancel-uuid": "cancelled"},
)
_make_task("development", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
# --- TC-86-04 (AC-2 / R1) --------------------------------------------------
def test_tc86_04_terminal_fallback_logical_key_empty_groups(monkeypatch):
"""Fallback when groups are unavailable ({}): terminality by the project's
logical done/cancelled key."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(
monkeypatch, state_uuid="done-key-uuid",
states={"done": "done-key-uuid", "cancelled": "cancel-key-uuid"},
groups={}, # group unknown -> logical-key fallback
)
_make_task("development", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
# --- TC-86-05 (AC-2) -------------------------------------------------------
def test_tc86_05_terminal_by_db_stage_cancelled(monkeypatch):
"""DB-side terminal drift: a task with stage='cancelled' (NOT filtered by
get_active_tasks_for_reconcile, which only drops 'done') is skipped locally
without reaching _note_unblock / advance — and bumps skipped_terminal_total."""
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
note = MagicMock()
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
# A networked resolve must not even be needed for the DB-side guard.
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(side_effect=AssertionError("must not hit Plane for DB-cancelled")),
)
_make_task("cancelled", wi="ET-002", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()
note.assert_not_called()
# --- TC-86-06 (AC-3) -------------------------------------------------------
def test_tc86_06_legit_unblock_passes_state_uuid(monkeypatch):
"""A legitimate unblock calls _note_unblock with a non-empty state_uuid; the
dedup guard stores issue_id -> state_uuid."""
_green_ci(monkeypatch)
# Default fixture: fetch_issue_state -> 'some-non-gated-state', groups {} ->
# not terminal, not blocked -> the task advances.
task_id = _make_task("development", wi="ET-300", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "review"
assert rec.unblocked_total == 1
assert rec._unblock_dedup.get("ET-300") == "some-non-gated-state"
# --- TC-86-07 (AC-3) -------------------------------------------------------
def test_tc86_07_repeat_tick_deduped(monkeypatch):
"""A repeat F-1 tick for the same issue+state_uuid is suppressed by the dedup
guard: deduped_total += 1 and no second send_telegram."""
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
# advance "succeeds" but leaves the stage put, so each tick reaches
# _note_unblock again with the SAME resolved state_uuid.
monkeypatch.setattr(
reconciler_mod, "advance_if_gate_passed",
MagicMock(return_value=MagicMock(advanced=True)),
)
_make_task("development", wi="ET-301", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once() # first: notifies
rec.reconcile_gate_once() # second: same issue+state -> deduped
assert tg.call_count == 1
assert rec.unblocked_total == 1
assert rec.deduped_total == 1
# --- TC-86-08 (AC-4, anti-regress) -----------------------------------------
def test_tc86_08_legit_unblock_still_notifies(monkeypatch):
"""A NON-terminal genuinely stuck task (working Plane status, past grace, no
active job, green gate) is STILL advanced and notifies exactly once."""
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
task_id = _make_task("development", wi="ET-302", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert _stage_of(task_id) == "review"
tg.assert_called_once()
assert rec.unblocked_total == 1
assert rec.skipped_terminal_total == 0
# --- TC-86-09 (AC-5, never-raise) ------------------------------------------
def test_tc86_09_never_raise_no_false_notify(monkeypatch):
"""An exception in the terminal-detect / fetch_issue_state path does not blow
up the tick AND does not produce a false unblock (conservative)."""
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
monkeypatch.setattr(
reconciler_mod, "fetch_issue_state",
MagicMock(side_effect=RuntimeError("plane boom")),
)
task_id = _make_task("development", wi="ET-303", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once() # must not raise
# resolve failed -> state_uuid None -> not terminal, Guard 2 conservative skip.
assert _stage_of(task_id) == "development"
tg.assert_not_called()
assert rec.unblocked_total == 0
# --- TC-86-11 (AC-6) -------------------------------------------------------
def test_tc86_11_terminal_skip_independent_of_guard2_flag(monkeypatch):
"""reconcile_skip_blocked_enabled=False (Guard 2 escape hatch) does NOT
disable the unconditional terminal-skip: a terminal task is still skipped."""
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_skip_blocked_enabled", False
)
spy = MagicMock()
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
_plane_terminal(monkeypatch) # group=completed
_make_task("development", wi="ET-304", age_s=3600)
rec = Reconciler()
rec.reconcile_gate_once()
assert rec.skipped_terminal_total == 1
spy.assert_not_called()

View File

@@ -684,3 +684,18 @@ def test_tc10_done_silent_on_all_projects(monkeypatch):
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total >= 2 # one per project
assert _job_count() == 0
# ---------------------------------------------------------------------------
# TC-86-10 (AC-6): the status()/GET-queue observability shape is unchanged by
# ORCH-086 — the ORCH-068 counters (skipped_terminal_total / deduped_total /
# unblocked_total) are still present, so the F-2 regression contract holds.
# ---------------------------------------------------------------------------
def test_tc86_10_status_shape_unchanged():
snap = Reconciler().status()
for key in (
"enabled", "plane_enabled", "interval", "last_run_ts",
"unblocked_total", "last_unblocked",
"skipped_terminal_total", "deduped_total",
):
assert key in snap, f"status() missing observability key: {key}"