Compare commits
37 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c4881b21 | ||
|
|
662d2d6434 | ||
|
|
90a5cae8e6 | ||
|
|
1d928dab57 | ||
| 9800dc89e3 | |||
| 5b80f8facb | |||
| a74379f657 | |||
| 9019e12d98 | |||
| 518d7d18c8 | |||
| 520bcafa73 | |||
| 9f7b6edb6d | |||
| 1c3ecb973e | |||
|
|
1b45fa0008 | ||
| 1f0929838a | |||
| 7deb151ce5 | |||
| aff334e82b | |||
| fa9b96545c | |||
| 319b23b4fc | |||
| e54d1fc4ac | |||
| 77abfb399c | |||
| 05bd169b14 | |||
|
|
183e6d68bc | ||
|
|
befa2979ec | ||
|
|
d33e0ded2e | ||
| de70ee811d | |||
| e1055861b5 | |||
| 2e84813c13 | |||
| 18f887c886 | |||
| 37ef58f21f | |||
| 0b9ae514c9 | |||
| c56672aabf | |||
| 0ed05417e6 | |||
| 7d99782673 | |||
| 59603f6e92 | |||
| d5f11e5caa | |||
| affbb259a1 | |||
| 8149eb7769 |
44
.env.example
44
.env.example
@@ -50,6 +50,43 @@ ORCH_MERGE_RETEST_TARGET=tests/
|
||||
ORCH_MERGE_LOCK_TIMEOUT_S=300
|
||||
ORCH_MERGE_DEFER_DELAY_S=60
|
||||
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
|
||||
# ORCH-026 Level A: unconditional pre-merge rebase. With the flag ON (default),
|
||||
# check_branch_mergeable ALWAYS rebases the branch onto origin/main under the held
|
||||
# merge-lease (not only when behind) — a deterministic structural anti-phantom on
|
||||
# the scheduler edge. No-op on an up-to-date branch (rebase keeps HEAD, force-with-
|
||||
# lease -> "Everything up-to-date", CI not triggered). Scope = ORCH_MERGE_GATE_REPOS.
|
||||
# PREMERGE_REBASE_ALWAYS=false -> strictly pre-ORCH-026 (rebase only when behind).
|
||||
ORCH_PREMERGE_REBASE_ALWAYS=true
|
||||
# ORCH-026 Level B: declarative task dependencies ("B waits for A"). claim_next_job
|
||||
# gates jobs whose depends-on tasks are not yet 'done' (additive job_deps table,
|
||||
# NOT EXISTS) WITHOUT occupying a max_concurrency slot. Inert on an empty job_deps.
|
||||
# TASK_DEPS_ENABLED=false -> claim query is 1:1 the ORCH-1 query (no gate).
|
||||
# TASK_DEPS_SOURCE=db|plane|hybrid -> declaration source; db (default) never calls
|
||||
# Plane on the hot path; plane/hybrid ingest Plane `blocked-by` relations and
|
||||
# cache them into job_deps (the scheduler then reads only the DB).
|
||||
ORCH_TASK_DEPS_ENABLED=true
|
||||
ORCH_TASK_DEPS_SOURCE=db
|
||||
# ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in
|
||||
# advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic
|
||||
# merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/
|
||||
# force-push to main), then `done` is allowed ONLY when the deployed SHA is proven an
|
||||
# ancestor of origin/main (ORCH-073 FR-1: SHA-in-main is the single criterion; a
|
||||
# merged PR alone no longer confirms). A secondary regression guard then checks a
|
||||
# declarative marker set (MAIN_REGRESSION_MARKERS) is still in origin/main; a missing
|
||||
# marker -> alert + HOLD (NOT done), a git error of the grep itself -> fail-open.
|
||||
# MERGE_VERIFY_ENABLED -> global kill-switch (false -> strictly pre-ORCH-071).
|
||||
# MERGE_VERIFY_REPOS -> CSV of repos where the under-gate is REAL; empty ->
|
||||
# only the self-hosting repo (orchestrator); non-self -> no-op.
|
||||
# MERGE_PR_TIMEOUT_S -> per Gitea list/merge HTTP call timeout.
|
||||
# MERGE_VERIFY_TIMEOUT_S -> git fetch/merge-base timeout for the ancestor + marker checks.
|
||||
# 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.
|
||||
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-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
|
||||
@@ -213,3 +250,10 @@ ORCH_POST_DEPLOY_FAIL_THRESHOLD=3
|
||||
ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5
|
||||
ORCH_POST_DEPLOY_AUTO_ROLLBACK=false
|
||||
ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500
|
||||
|
||||
# ── QG-0 entry validation (ORCH-069) ──────────────────────────────────────────
|
||||
# Upper title-length limit for the QG-0 entry gate (_qg0_errors). The old 80-char
|
||||
# cap was a hygiene limit, not structural (slug is cut to [:30] independently, the
|
||||
# DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully
|
||||
# degrades to 200 (the process never crashes on startup).
|
||||
ORCH_QG0_TITLE_MAX=200
|
||||
|
||||
@@ -50,3 +50,6 @@ ORCH_QUEUE_POLL_INTERVAL=2.0
|
||||
DEPLOY_SSH_USER=slin
|
||||
DEPLOY_SSH_HOST=127.0.0.1
|
||||
DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||
|
||||
# QG-0 entry title-length limit (ORCH-069). Default 200; invalid/empty -> 200.
|
||||
ORCH_QG0_TITLE_MAX=200
|
||||
|
||||
13
.gitattributes
vendored
Normal file
13
.gitattributes
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# ORCH-073 (ADR-001 Р-5 / FR-4): union merge for the append-only changelog.
|
||||
#
|
||||
# CHANGELOG.md is append-only at the top (## [Unreleased]). Without a merge driver,
|
||||
# two branches that both add an Unreleased entry collide on auto_rebase_onto_main
|
||||
# (merge_gate), which rolls the branch back to `development` and can drag in stale
|
||||
# neighbouring code (a phantom-merge amplifier — see ADR-001 root cause #3). The
|
||||
# built-in `union` driver keeps BOTH sides' lines instead of conflicting, so both
|
||||
# changelog entries survive and the branch is not rolled back.
|
||||
#
|
||||
# Scope is INTENTIONALLY limited to CHANGELOG.md: `union` only suits strictly
|
||||
# append-only files. docs/**/*.md (README, ADR, internals) are rewritten line-by-line,
|
||||
# where `union` would silently duplicate edited lines — so they are NOT included.
|
||||
CHANGELOG.md merge=union
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
|
||||
- Backend: FastAPI + uvicorn (Python 3.12)
|
||||
- БД: SQLite (`src/db.py`)
|
||||
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`
|
||||
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1)
|
||||
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`).
|
||||
- Контейнеризация: Docker + Compose
|
||||
- CI/CD: Gitea Actions (`.gitea/workflows/`)
|
||||
- Деплой: docker compose на mva154
|
||||
|
||||
@@ -136,6 +136,7 @@ uvicorn src.main:app --reload --port 8500
|
||||
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` |
|
||||
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` |
|
||||
| `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` |
|
||||
| `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` |
|
||||
|
||||
## Очередь задач (ORCH-1 / F-2b)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
|
||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
|
||||
- **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.
|
||||
@@ -59,11 +59,24 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
|
||||
|
||||
Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием:
|
||||
- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`.
|
||||
- **Безусловный pre-merge rebase (ORCH-026, A-2):** при `premerge_rebase_always` (дефолт `True`, скоуп `merge_gate_repos`) short-circuit `branch_is_behind_main` пропускается — `auto_rebase_onto_main` вызывается **всегда** под лизом. На актуальной ветке это no-op (`rebase` не меняет HEAD, `push --force-with-lease` → «Everything up-to-date», CI не триггерится); на отстающей — реальный догон. Детерминированный структурный анти-фантом на уровне планировщика (дополняет рубежи ORCH-073, не заменяет). Kill-switch `premerge_rebase_always=False` → прежнее поведение (ребейз только при behind).
|
||||
- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
|
||||
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД.
|
||||
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД. **ORCH-026 (A-1):** это окно = «merge → main-updated» (для self `done` ⇔ SHA-in-main, ORCH-073) — пока A не в `main`, B того же репо получает `merge-lock busy` → defer. Окно сериализации per-repo НЕ переписывается; кросс-репо параллелизм сохранён (лиз — per-repo файл).
|
||||
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**.
|
||||
|
||||
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
|
||||
Безусловный pre-merge rebase + связь с зависимостями задач — [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md) (ORCH-026).
|
||||
|
||||
### Зависимости задач: B ждёт A (ORCH-026, Уровень B)
|
||||
Плоская очередь ORCH-1 (FIFO по `id` + `available_at` + `max_concurrency`) не выражала логических зависимостей. ORCH-026 вводит декларативные связи «задача B не стартует, пока не готовы её depends-on» — без новой стадии и без изменения `STAGE_TRANSITIONS`/`QG_CHECKS`.
|
||||
- **Источник истины планировщика — БД** (аддитивная таблица `job_deps(task_id, depends_on_task_id)`): claim в горячем цикле обслуживает очередь ВСЕХ проектов и обязан быть offline-устойчив (сетевой Plane на каждый claim = встанет очередь всех проектов). Источник **декларации** настраивается `task_deps_source = db|plane|hybrid` (дефолт `db`; `plane`/`hybrid` читают Plane relations в `handle_work_item_created` и кэшируют в `job_deps`).
|
||||
- **Гейт планировщика (`claim_next_job`)** — условие `NOT EXISTS (job_deps d JOIN tasks t … WHERE d.task_id=j.task_id AND t.stage!='done')` при `task_deps_enabled`: задача с незавершённой зависимостью **не выбирается** (агент не запускается, слот `max_concurrency` не занимается). Инертно при пустой `job_deps` → нулевая регрессия; kill-switch `task_deps_enabled=False` → запрос 1:1 как ORCH-1.
|
||||
- **Детект дедлоков** — DFS-цикл-детектор (leaf `src/task_deps.py::detect_cycle`) при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert (Telegram/Plane) с перечислением цикла. Поток остальных задач не блокируется.
|
||||
- **Видимость** — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (`update_task_tracker`, never-raise); Plane `Blocked` — на дедлоке (не на нормальном коротком ожидании, чтобы не флаппить). Инвариант «одна карточка на задачу» сохранён.
|
||||
- **Совместимость:** `reconciler` F-1 пропускает dep-заблокированные задачи (`is_task_ready`, паттерн ORCH-060); `reaper` сканирует только `running` → dep-блок остаётся `queued`, не трогается. Зависимости — только intra-repo (v1).
|
||||
- **Наблюдаемость:** блок `task_deps` в `GET /queue` (заблокированные задачи, держатель merge-lease, defer-счётчики, обнаруженные циклы) — read-only.
|
||||
|
||||
Подробнее: [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md), детально — `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
|
||||
|
||||
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
|
||||
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
|
||||
@@ -140,25 +153,44 @@ merge-в-main вообще**. Detached host-деплой лишь retag'ал о
|
||||
- **Merge в Phase C (после рестарта), НЕ в Phase B** — finalizer restart-surviving (claim воркером
|
||||
нового контейнера, re-drive reaper'ом), merge физически строго ПОСЛЕ рестарта прода → рестарт его
|
||||
не убивает (G3 «шаг, переживающий рестарт»; постмортем-урок №3).
|
||||
- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (no-op повтор, ORCH-065) → иначе
|
||||
Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Никогда push/force-push в `main`.
|
||||
- **Верификатор `merge_gate.verify_merged_to_main`** — `PR.merged==true` ИЛИ
|
||||
`git merge-base --is-ancestor <validated_sha> origin/main` (`validated_revision` — тот же якорь,
|
||||
что у ORCH-058). never-raise → `False`.
|
||||
- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (idempotency no-op повтор) → иначе
|
||||
Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Выбор PR строго по `head.ref==branch`
|
||||
И `base.ref=="main"`. Никогда push/force-push в `main`.
|
||||
- **Верификатор `merge_gate.verify_merged_to_main` (семантика ORCH-073, FR-1):** подтверждение —
|
||||
**ТОЛЬКО** `git merge-base --is-ancestor <validated_sha> origin/main` (`validated_revision` —
|
||||
якорь ORCH-058). PR-флаг `pr_already_merged` **больше НЕ подтверждает merge** (удалён из verify):
|
||||
он понижен до idempotency-guard `merge_pr` и засчитывает merged PR лишь при `head.ref==branch`
|
||||
И `base.ref=="main"` (исключает авто docs-PR). Пустой SHA / git-ошибка → `False` (fail-closed),
|
||||
never-raise.
|
||||
- **Регресс-гард целостности `main` (ORCH-073, FR-5):** `merge_gate.check_main_regression` в
|
||||
`_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done` проверяет, что `origin/main`
|
||||
содержит декларативный набор маркеров ранее-merged задач (`MAIN_REGRESSION_MARKERS`,
|
||||
`git grep -c <marker> origin/main -- <path>` > 0). Маркер отсутствует → alert «main regressed» +
|
||||
HOLD (НЕ `done`, ALERT-only). Fail-open на git-ошибке грепа (регресс — только при `count==0`).
|
||||
Kill-switch `regression_guard_enabled`; non-self → no-op. Набор — append-only константа,
|
||||
значимая задача дописывает свой маркер.
|
||||
- **Не подтверждено → alert «deploy succeeded but not merged» (Telegram+Plane) + HOLD**
|
||||
(`set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на `development` — not-merged есть
|
||||
инфра-дефект, реакция ALERT-only как ORCH-021 self-hosting). Подтверждено → штатный `deploy →
|
||||
done` + `merged_to_main: true` во frontmatter `14-deploy-log.md` (`deploy_status:` нетронут).
|
||||
- **Защита от CHANGELOG-затирания (ORCH-073, FR-4):** корневой `.gitattributes` с
|
||||
`CHANGELOG.md merge=union` → правки `## [Unreleased]` авто-сливаются при `auto_rebase_onto_main`
|
||||
без конфликта, ветка не откатывается в `development` и не тащит устаревший код-сосед. `docs/**`
|
||||
под union НЕ ставится (union только для append-only).
|
||||
- **Условность как ORCH-35/43/58:** `merge_verify_enabled` (kill-switch, дефолт `true`) +
|
||||
`merge_verify_repos` (пусто → только self-hosting); non-self — no-op, merge остаётся за `deployer`.
|
||||
never-raise; идемпотентность (`pr_already_merged`, INV-5); ручной approve сохранён (`Confirm Deploy`).
|
||||
never-raise; идемпотентность по **SHA-в-main** (INV-4, не «любой merged PR»); ручной approve
|
||||
сохранён (`Confirm Deploy`).
|
||||
- **Инварианты:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, реестр
|
||||
`QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG), схема БД,
|
||||
БАГ-8, terminal-sync, merge-gate, image-freshness, exit-коды хука — **без изменений**.
|
||||
Диагностика фантома — runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки постмортема).
|
||||
|
||||
Подробнее: [adr-0013](adr/adr-0013-merge-verify-gate.md), детально —
|
||||
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`.
|
||||
Подробнее: [adr-0013](adr/adr-0013-merge-verify-gate.md) +
|
||||
[adr-0014](adr/adr-0014-merge-verify-sha-source-of-truth.md) (amends 0013 — SHA-в-main как
|
||||
единственный критерий + регресс-гард, ORCH-073); детально —
|
||||
`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`.
|
||||
|
||||
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
||||
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
||||
@@ -433,6 +465,7 @@ Monitoring after Deploy → Done
|
||||
- `tasks` — задачи и их стадии
|
||||
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
|
||||
- `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A»
|
||||
|
||||
## Изоляция (git worktree, ORCH-2)
|
||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||
|
||||
@@ -17,11 +17,16 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
|
||||
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
|
||||
| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 |
|
||||
| adr-0012 | Security-гейт (secrets/deps) | accepted | 2026-06-08 | ORCH-022 |
|
||||
| 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 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0011`).
|
||||
> свободный номер (текущий максимум — `0015`).
|
||||
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# adr-0014: SHA-в-main — единственный критерий merge-verify + регресс-гард целостности `main`
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-08
|
||||
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
|
||||
- **Amends:** [adr-0013](adr-0013-merge-verify-gate.md) (ORCH-071) — меняет КРИТЕРИЙ подтверждения merge.
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`
|
||||
- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md`
|
||||
|
||||
## Контекст
|
||||
|
||||
adr-0013 (ORCH-071) ввёл под-гейт merge-verify на ребре `deploy → done`, но допускал
|
||||
подтверждение merge по **ИЛИ-критерию**: `verify_merged_to_main` возвращал `True`, если
|
||||
`pr_already_merged(repo, branch)` **ЛИБО** SHA — предок `origin/main`. `pr_already_merged`
|
||||
засчитывал **любой** merged PR ветки, включая авто docs-PR (staging/deploy-логи). У одной
|
||||
feature-ветки в `main` сливались только docs-PR, а code-PR — нет → `pr_already_merged`=`True` →
|
||||
verify `CONFIRMED` → `done`, хотя кода в `main` не было. Накопительно потеряны ORCH-067 (ссылки
|
||||
`plane_issue_link`) и ORCH-069 (`qg0_title_max`). Вторичный усилитель — CHANGELOG-ребейзы,
|
||||
откатывающие ветку и тащащие устаревший код-сосед. Восстановление кода (G1) выполнено вручную
|
||||
restore-PR #76; этот ADR устраняет корень навсегда.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **SHA-в-main — единственный критерий (FR-1).** `verify_merged_to_main(repo, branch, sha)`
|
||||
подтверждает merge **ТОЛЬКО** прямым фактом `git merge-base --is-ancestor <sha> origin/main`
|
||||
(после `git fetch origin main`). OR-ветка `pr_already_merged` **удалена** из верификатора.
|
||||
Пустой `sha` / любая git-ошибка → `False` (fail-closed: alert + HOLD). never-raise (INV-1).
|
||||
2. **`pr_already_merged` → idempotency-guard, различающий code-PR/docs-PR (FR-2).** Засчитывает
|
||||
merged PR только при `head.ref==<feature-branch>` И `base.ref=="main"` (явный фильтр в цикле,
|
||||
не ненадёжный query-параметр `head`). Используется лишь как защита `merge_pr` от второго merge,
|
||||
НЕ как подтверждение `done`.
|
||||
3. **`merge_pr` сливает именно code-ветку (FR-3).** Выбор открытого PR по `head.ref==branch` И
|
||||
`base.ref=="main"`; merge только Gitea `POST /pulls/{index}/merge`, никогда push/force-push в
|
||||
`main`. Источник истины «слилось» — FR-1.
|
||||
4. **Регресс-гард целостности `main` (FR-5).** Новая `merge_gate.check_main_regression`,
|
||||
вызываемая в `_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done`: проверяет, что
|
||||
`origin/main` содержит **декларативный набор маркеров** ключевых функций ранее-merged задач
|
||||
(`git grep -c <marker> origin/main -- <path>` > 0). Маркер отсутствует → **alert «main
|
||||
regressed» + HOLD** (НЕ `done`, БЕЗ авто-отката на `development` — инфра-дефект, ALERT-only как
|
||||
ORCH-021/071). Набор — append-only константа `MAIN_REGRESSION_MARKERS` в `merge_gate.py`
|
||||
(расширяется каждой значимой задачей). **Fail-open** на git-ошибке самого грепа (регресс
|
||||
утверждается только при детерминированном `count==0`); первичный фейл-клозед — SHA-в-main.
|
||||
Kill-switch `regression_guard_enabled` (дефолт `true`); non-self → no-op.
|
||||
5. **`.gitattributes CHANGELOG.md merge=union` (FR-4).** В корне репо; авто-слияние правок
|
||||
`## [Unreleased]` без конфликта → `auto_rebase_onto_main` не откатывает ветку и не тащит
|
||||
устаревший код-сосед. `docs/**/*.md` под union **НЕ** ставится (union только для append-only;
|
||||
доки переписываются построчно).
|
||||
|
||||
## Инварианты
|
||||
|
||||
never-raise на verify/merge/регресс-гарде (ошибка → alert/HOLD, не падение); прод 8500 не
|
||||
рестартится/не падает в рамках merge; merge только Gitea PR-API без force-push в `main`; ручной
|
||||
`Confirm Deploy` (ORCH-059) сохранён; идемпотентность по «SHA-в-main», а не по «любому merged PR»;
|
||||
non-self репо (enduro) — merge/verify/регресс-гард без изменений. `STAGE_TRANSITIONS`, реестр
|
||||
`QG_CHECKS`, `check_deploy_status`, схема БД, внешние HTTP-эндпоинты — **без изменений**.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- Сохранить PR-флаг как со-критерий verify (с фильтром head/base) — отклонено: PR можно слить и
|
||||
тут же откатить ребейзом-соседом; надёжен только факт «SHA в main».
|
||||
- `docs/**/*.md merge=union` — отклонено: тихая дубликация строк в переписываемых доках.
|
||||
- Регресс-гард с авто-откатом / хранением маркеров в БД/Plane — отклонено (Не-цель «не менять
|
||||
схему БД/Plane»; реакция ALERT-only).
|
||||
- Fail-closed на marker-grep — отклонено: ложный HOLD при git-сбое; marker-grep вторичен.
|
||||
|
||||
## Последствия
|
||||
|
||||
Невозможно «`done` + прод задеплоен, а code-PR не в `main`». Ложно-зелёный по docs-PR устранён в
|
||||
корне. CHANGELOG-конфликты больше не откатывают ветку. Регресс соседнего кода ловится отдельным
|
||||
гардом. Минус: при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
|
||||
(снимается повтором; fail-closed для `done` приоритетен). Набор маркеров требует дисциплины —
|
||||
значимая задача дописывает свой маркер.
|
||||
|
||||
## Связи
|
||||
|
||||
- Amends adr-0013 (ORCH-071), наследует adr-0006 (merge-gate), adr-0011 (job-reaper/lease).
|
||||
- Детально: `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.
|
||||
@@ -0,0 +1,47 @@
|
||||
# adr-0015: Зависимости задач + сериализация merge внутри репо
|
||||
|
||||
**Статус:** accepted · **Дата:** 2026-06-08 · **Источник:** ORCH-026
|
||||
**Связи:** дополняет adr-0006 (merge-gate), adr-0011 (merge-lease + reclaim), adr-0013/0014
|
||||
(merge-verify, SHA-in-main), adr-0002 (очередь). Детально —
|
||||
`docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
|
||||
|
||||
## Контекст
|
||||
|
||||
Эрозия `main` 08.06 родилась из некоординированного параллелизма задач одного репо (ветки от
|
||||
устаревшего `main`, фантом-merge затирает соседа). adr-0014 закрыл последствия; ORCH-026 — корень
|
||||
на уровне планировщика. Плюс исходный скоуп ORCH-026: декларативные зависимости задач (B ждёт A).
|
||||
|
||||
## Решение
|
||||
|
||||
**Уровень A — сериализация merge/деплоя (per-repo).** Окно сериализации уже обеспечивается
|
||||
merge-lease (adr-0011): захват в `check_branch_mergeable`, удержание до release (PR-merged webhook /
|
||||
`deploy→done`=SHA-in-main для self / откат / проактивный reclaim). Это и есть окно
|
||||
«merge → main-updated» — **механизм не переписывается**. Добавляется единственное новое поведение:
|
||||
**безусловный proactive pre-merge rebase** (флаг `premerge_rebase_always`, дефолт `True`, скоуп
|
||||
`merge_gate_repos`): под лизом всегда вызывается `auto_rebase_onto_main` (no-op + «Everything
|
||||
up-to-date» на актуальной ветке → CI не триггерится; реальный догон на отстающей). Инвариант:
|
||||
никаких push в `main`, force только `--force-with-lease` на ветку.
|
||||
|
||||
**Уровень B — декларативные зависимости.** Аддитивная таблица `job_deps(task_id,
|
||||
depends_on_task_id)` — **источник истины планировщика** (offline-устойчивость: сетевой Plane в
|
||||
горячем claim встанет очередью всех проектов). Источник декларации настраивается
|
||||
`task_deps_source = db|plane|hybrid` (дефолт `db`); планировщик всегда читает БД-кэш. Гейт —
|
||||
условие `NOT EXISTS` в `claim_next_job` (задача не выбирается, пока есть незавершённая зависимость;
|
||||
слот `max_concurrency` не занимается). Циклы — DFS-детектор (`src/task_deps.py`) + `set_issue_blocked`
|
||||
+ alert. Видимость — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (Plane Blocked — на дедлоке).
|
||||
Зависимости — только intra-repo (v1).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
Отдельный merge-lock/merge-queue (дублирует adr-0011); расширение release-точек лиза (не нужно —
|
||||
окно уже корректно); Plane как источник истины планировщика (self-hosting risk); гейт зависимостей
|
||||
в воркере с claim+requeue (churn vs. чистый `NOT EXISTS`); поле в `tasks` вместо таблицы (M:N хуже).
|
||||
|
||||
## Последствия
|
||||
|
||||
Минимально-инвазивно: `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки), переиспользует
|
||||
merge-gate/merge-lease целиком. Обе фичи инертны без данных → нулевая регрессия для enduro-trails.
|
||||
restart-safe, never-raise, kill-switch на каждую (`premerge_rebase_always`, `task_deps_enabled`).
|
||||
Миграция — только аддитивная (`CREATE TABLE/INDEX IF NOT EXISTS`). Ограничение: B v1 — intra-repo.
|
||||
Self-hosting safety: изменения идут через `deploy-staging` → `Confirm Deploy`, без внеочередного
|
||||
рестарта прода.
|
||||
7
docs/work-items/ORCH-026/00-business-request.md
Normal file
7
docs/work-items/ORCH-026/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Управление зависимостями задач (B ждёт A) в очереди
|
||||
|
||||
Work Item ID: ORCH-026
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
135
docs/work-items/ORCH-026/01-brd.md
Normal file
135
docs/work-items/ORCH-026/01-brd.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 01-BRD — Управление зависимостями задач (B ждёт A) в очереди
|
||||
|
||||
**Work Item:** ORCH-026
|
||||
**Repo:** orchestrator (self-hosting)
|
||||
**Branch:** feature/ORCH-026-b-a
|
||||
**Стадия:** analysis
|
||||
**Источник:** предложение Стрим, одобрено Славой (2026-06-04); дополнение Слава+Стрим 2026-06-08 (инцидент эрозии `main`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
### 1.1 Первопричина (мотивация СЕЙЧАС — инцидент 08.06)
|
||||
Эрозия `main` 08.06 (потеря кода ORCH-067/069, фантом-merge) родилась НЕ из логических
|
||||
зависимостей, а из **некоординированного параллелизма**: несколько self-hosting задач
|
||||
(ORCH-067/069/071) одновременно срезали ветки от `main` и правили общие файлы
|
||||
(`CHANGELOG.md`, `notifications.py`, `config.py`). Последствия:
|
||||
|
||||
- CHANGELOG-конфликты на `auto_rebase` → откаты `deploy-staging → development` (дорого:
|
||||
ORCH-069 = 3 попытки = $3.98);
|
||||
- тихое затирание кода соседа при merge ветки, срезанной от устаревшего `main` (фантом).
|
||||
|
||||
**ORCH-073** закрыл ПОСЛЕДСТВИЯ (3 рубежа: CHANGELOG `merge=union` + SHA-in-main verify +
|
||||
регресс-гард маркеров). ORCH-026 должен закрыть **ПЕРВОПРИЧИНУ**: задачи одного репо не
|
||||
должны мешать друг другу в `main`.
|
||||
|
||||
### 1.2 Исходный скоуп (плоская очередь ORCH-1)
|
||||
Очередь (`src/queue_worker.py`, ORCH-1) — плоская: `jobs` упорядочены по `id` (FIFO),
|
||||
гейтятся только `available_at` и `max_concurrency`. Нельзя выразить «задача B не стартует,
|
||||
пока не готова A». Декомпозиция эпиков (ORCH-025) порождает заведомо зависимые подзадачи.
|
||||
|
||||
### 1.3 Что уже есть (опора, НЕ переписывать)
|
||||
- **ORCH-1** — персистентная очередь (`jobs`), atomic claim, `available_at`-defer, restart-safe.
|
||||
- **ORCH-065** — `merge-lease` (`src/merge_gate.py`): per-repo файловый лиз
|
||||
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный
|
||||
реклейм мёртвого/устаревшего держателя. **Сейчас лиз держится только на ребре
|
||||
`deploy-staging → deploy`** (от merge-gate до фактического merge).
|
||||
- **ORCH-043** — merge-gate: `branch_is_behind_main`, `auto_rebase_onto_main` (rebase
|
||||
**только когда ветка отстаёт или при конфликте**), `retest_branch`.
|
||||
- **ORCH-073** — merge-verify: `verify_merged_to_main` (SHA-in-main), `check_main_regression`.
|
||||
- **Plane-статусы** `Blocked` / `Needs Input` + `set_issue_blocked` (`src/plane_sync.py`).
|
||||
- **Telegram live-tracker** (`src/notifications.py`) — одна карточка на задачу, уже умеет
|
||||
показывать статус `Blocked`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Цель (бизнес-результат)
|
||||
|
||||
Задачи одного репозитория перестают повреждать `main` друг друга, а очередь умеет
|
||||
выражать логические зависимости между задачами — БЕЗ потери параллелизма между разными
|
||||
репозиториями и без риска для self-hosting прода.
|
||||
|
||||
---
|
||||
|
||||
## 3. Два уровня требований (объединить в одной задаче; приоритет — Уровень A)
|
||||
|
||||
### Уровень A — Сериализация merge/деплоя внутри ОДНОГО репо (КРИТИЧНО, корень эрозии)
|
||||
Закрывает первопричину инцидента 08.06.
|
||||
|
||||
- **A-1.** В рамках ОДНОГО репо merge-в-`main` + деплой должны быть **сериализованы**: пока
|
||||
задача A не слита в `main` (и для self-hosting — не задеплоена), задача B того же репо НЕ
|
||||
доходит до своего merge/деплоя от устаревшего `main`.
|
||||
- **A-2.** B перед своим merge-gate **обязана ребейзнуться на СВЕЖИЙ `main`** (где уже есть
|
||||
A) — **proactive pre-merge rebase**, а не только при текстовом конфликте (как сейчас в
|
||||
ORCH-043). Цель: B всегда несёт актуальный код предшественников → структурный анти-фантом
|
||||
на уровне планировщика (дополняет рубежи ORCH-073, не заменяет).
|
||||
- **A-3.** Сериализация — **только внутри одного репо**. Задачи РАЗНЫХ репо (orchestrator vs
|
||||
enduro-trails) параллелятся свободно (общая БД/очередь — пропускная способность не падает).
|
||||
- **A-4.** Механизм — минимально-инвазивный и **restart-safe** (как ORCH-1/065): переживает
|
||||
рестарт прод-контейнера, не оставляет навсегда захваченных ресурсов (опора на проактивный
|
||||
реклейм ORCH-065).
|
||||
- **A-5.** **Совместимость с self-hosting safety:** не ронять/не рестартить прод-контейнер
|
||||
вне штатного deploy; гейт `Confirm Deploy` (ORCH-059) сохранён; никаких push/force-push в
|
||||
`main`.
|
||||
- **A-6.** Защита от взаимоблокировки: B при занятой сериализации **defer** (повторная
|
||||
постановка с задержкой через `available_at`), а НЕ откат на `development` и НЕ вечное
|
||||
ожидание; bounded defer-бюджет (анти-livelock, как `merge_defer_max_attempts`).
|
||||
|
||||
### Уровень B — Декларативные зависимости (исходный скоуп ORCH-26)
|
||||
- **B-1.** Задача может объявить связь `blocked-by` / `blocks` (depends-on).
|
||||
- **B-2.** Планировщик очереди (ORCH-1) **не запускает** заблокированную задачу, пока все её
|
||||
depends-on не достигли терминального состояния (`done`).
|
||||
- **B-3.** **Защита от дедлоков:** циклические зависимости детектируются; задача в цикле не
|
||||
«пропадает молча» — выставляется `Blocked` + alert (Telegram/Plane).
|
||||
- **B-4.** **Видимость:** заблокированная задача видна — Plane-статус `Blocked` и/или
|
||||
ожидание в Telegram-карточке (что и кого ждёт).
|
||||
|
||||
---
|
||||
|
||||
## 4. Открытые вопросы для архитектора (НЕ решаются на этапе анализа)
|
||||
|
||||
> Аналитик фиксирует требования; выбор механизма — за архитектором (ADR в `06-adr/`).
|
||||
|
||||
1. **Где хранить связи (Уровень B):** Plane relations (родное, видимо в UI, но требует
|
||||
сетевого запроса и зависит от Plane) vs таблица в БД (`job_deps`/поля `tasks`, надёжно и
|
||||
offline, но дубль источника) vs **гибрид** (Plane — источник декларации, БД — кэш для
|
||||
планировщика). Рекомендация анализа: гибрид с offline-fallback (см. §6).
|
||||
2. **Механизм сериализации (Уровень A):** глобальный per-repo merge-lock vs FIFO merge-queue
|
||||
vs **обязательный pre-merge rebase + расширение окна merge-lease** (от «момента merge» до
|
||||
«main-updated»). Выбрать минимально-инвазивный, restart-safe, переиспользующий ORCH-065/043.
|
||||
3. **Граница окна сериализации для self-hosting:** для не-self репо «merged в main» = конец
|
||||
окна; для self (orchestrator) деплой асинхронный (Phase B/C, ORCH-036/071) — нужно решить,
|
||||
до какого события держать лиз (до `merged_to_main: true` / до `done`).
|
||||
4. **Совместимость B и A:** depends-on (B) на уровне постановки в очередь vs merge-сериализация
|
||||
(A) на уровне merge-gate — разные точки конвейера; убедиться, что не конфликтуют.
|
||||
|
||||
---
|
||||
|
||||
## 5. Вне скоупа (Non-goals)
|
||||
- Изменение машины стадий `STAGE_TRANSITIONS` (сериализация/зависимости — врезки/гейты, не
|
||||
новые стадии — паттерн ORCH-043/058/071).
|
||||
- Приоритизация/перепланирование задач по весам (только зависимости и сериализация).
|
||||
- Кросс-репо зависимости (A-3 явно запрещает кросс-репо сериализацию; кросс-репо логические
|
||||
зависимости — возможный follow-up, не v1).
|
||||
- Отмена/замена рубежей ORCH-073 — ORCH-026 их **дополняет** на уровне планировщика.
|
||||
|
||||
---
|
||||
|
||||
## 6. Заинтересованные стороны
|
||||
- **Owner (Слава)** — одобряет BRD; держатель self-hosting прод-риска.
|
||||
- **Стрим** — автор предложения.
|
||||
- **Конвейер агентов** — потребитель: developer/deployer работают с веткой, которую затрагивает
|
||||
сериализация; reviewer проверяет обновление доки.
|
||||
|
||||
---
|
||||
|
||||
## 7. Критерии успеха (бизнес-уровень)
|
||||
- Две зелёные задачи одного репо больше не способны затереть код друг друга в `main` на уровне
|
||||
планировщика (без участия рубежей-последствий ORCH-073).
|
||||
- Задача может объявить зависимость; заблокированная задача не стартует раньше времени и видна
|
||||
наблюдателю.
|
||||
- Пропускная способность разных репо не деградирует.
|
||||
- Прод-контейнер orchestrator не падает и не рестартится вне штатного `Confirm Deploy`.
|
||||
|
||||
Точные PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
134
docs/work-items/ORCH-026/02-trz.md
Normal file
134
docs/work-items/ORCH-026/02-trz.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 02-ТЗ — Управление зависимостями задач (B ждёт A) в очереди
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
|
||||
|
||||
> ТЗ фиксирует ТРЕБОВАНИЯ к изменениям (модули, контракты, артефакты). Конкретный механизм
|
||||
> сериализации и место хранения связей — решение архитектора (ADR в `06-adr/`); ниже отмечены
|
||||
> как «КАНДИДАТ / решает архитектор». Аналитик не предлагает архитектуру.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче | Уровень |
|
||||
|--------|---------------|---------|
|
||||
| `src/queue_worker.py` | Планировщик: `_drain_once` / `claim_next_job` — точка учёта зависимостей и сериализации при выборе job. | A + B |
|
||||
| `src/db.py` | Очередь `jobs` / `tasks`; `claim_next_job`, `enqueue_job`, `count_running_jobs`. Кандидат на хранение связей и блокировки claim. | A + B |
|
||||
| `src/merge_gate.py` | merge-lease (ORCH-065), `branch_is_behind_main` / `auto_rebase_onto_main` (ORCH-043) — опора для proactive pre-merge rebase и расширения окна сериализации. | A |
|
||||
| `src/qg/checks.py` | `check_branch_mergeable` (под-гейт ребра `deploy-staging → deploy`) — точка форсированного pre-merge rebase. | A |
|
||||
| `src/stage_engine.py` | `advance_stage` — врезки гейтов; точка интеграции сериализации/верификации. | A |
|
||||
| `src/webhooks/plane.py` | `handle_work_item_created` / `start_pipeline` — приём задачи; точка чтения relations (если источник — Plane). | B |
|
||||
| `src/plane_sync.py` | `set_issue_blocked`, `get_project_states` (`blocked`/`needs_input`), relations API. | B |
|
||||
| `src/notifications.py` | live-карточка: индикация `Blocked` / «ждёт ORCH-NNN». | B |
|
||||
| `src/config.py` | Новые kill-switch + scope-настройки (паттерн `*_enabled` / `*_repos`). | A + B |
|
||||
| `src/reconciler.py` / `src/job_reaper.py` | Не ломать: skip заблокированных задач (как уже делается для Blocked/Needs-Input, ORCH-060/068); реклейм ресурсов сериализации. | A + B |
|
||||
|
||||
---
|
||||
|
||||
## 2. Требования к изменениям — Уровень A (сериализация merge/деплоя)
|
||||
|
||||
### 2.1 Proactive pre-merge rebase (A-2)
|
||||
- На ребре `deploy-staging → deploy`, ДО фактического merge (в составе `check_branch_mergeable`
|
||||
или соседнего под-гейта), ветка задачи **всегда** догоняется на свежий `origin/main` —
|
||||
**не только при `branch_is_behind_main`/конфликте**.
|
||||
- Переиспользовать `merge_gate.auto_rebase_onto_main` (rebase + `push --force-with-lease`
|
||||
ТОЛЬКО ветки задачи). Текстовый конфликт → существующий контракт: `rebase --abort` → откат на
|
||||
`development` (как ORCH-043).
|
||||
- **Инвариант:** никаких push/force-push в `main`.
|
||||
|
||||
### 2.2 Расширение окна merge-lease (A-1, A-3, A-4)
|
||||
- **КАНДИДАТ (решает архитектор):** держать per-repo merge-lease (ORCH-065) не только «на
|
||||
момент merge», а на окно **«merge → main-updated»** (для self — до подтверждения
|
||||
`merged_to_main: true` / `done`), чтобы B не дошла до своего merge, пока A не в `main`.
|
||||
- Acquire — **неблокирующий** (как сейчас): занято → **defer** задачи B через
|
||||
`enqueue_job(available_at_delay_s=...)`, bounded бюджет (анти-livelock; ср.
|
||||
`merge_defer_max_attempts`). Откат на `development` НЕ применять для defer.
|
||||
- Release — holder-aware (как `release_merge_lease`), на merged-вебхуке / `deploy→done` /
|
||||
откате / по проактивному реклейму (ORCH-065 `reclaim_stale_lease`).
|
||||
- Сериализация **строго per-repo** (`.merge-lease-<repo>.json`) — кросс-репо параллелизм не
|
||||
затрагивается (A-3).
|
||||
|
||||
### 2.3 Условность и безопасность (A-5)
|
||||
- Реально только для применимых репо: kill-switch + CSV-scope (паттерн `merge_gate_repos` /
|
||||
`merge_verify_repos`; пусто → только self-hosting `orchestrator`).
|
||||
- `STAGE_TRANSITIONS`, `Confirm Deploy` (ORCH-059), exit-коды deploy-хука, БАГ-8,
|
||||
terminal-sync — **без изменений**.
|
||||
- Контракт **never-raise** для всех новых функций (как соседи в `merge_gate.py`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Требования к изменениям — Уровень B (декларативные зависимости)
|
||||
|
||||
### 3.1 Декларация связи (B-1)
|
||||
- **КАНДИДАТ хранения (решает архитектор, см. BRD §4.1):**
|
||||
- вариант Plane relations: читать `blocked-by` через Plane API в `handle_work_item_created`;
|
||||
- вариант БД: новая таблица `job_deps(task_id, depends_on_task_id)` или поле в `tasks`
|
||||
(idempotent `_ensure_column` миграция, как ORCH-065 `jobs.pid`);
|
||||
- гибрид: Plane — декларация, БД — кэш для планировщика (offline-устойчивость).
|
||||
- Миграция БД (если выбран вариант с таблицей/колонкой) — **только аддитивная**
|
||||
(`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), безопасная на живой прод-БД с общими
|
||||
данными enduro-trails.
|
||||
|
||||
### 3.2 Гейт планировщика (B-2)
|
||||
- При выборе job (`claim_next_job` / `_drain_once`) задача с незавершёнными depends-on
|
||||
**не клеймится** (аналог `available_at`-gate): пропускается до тех пор, пока все depends-on
|
||||
не `done`. Не должна занимать слот `max_concurrency`.
|
||||
- Реализация — **leaf-функция** с чистой логикой «готова ли задача к запуску» (тестируемо
|
||||
юнитами, never-raise), по образцу `staging_verdict.py` / `post_deploy.py`.
|
||||
|
||||
### 3.3 Защита от дедлоков (B-3)
|
||||
- Детектор циклов в графе depends-on (DFS/обнаружение цикла) — чистая функция, юнит-тестируемая.
|
||||
- Цикл → задача(и) НЕ запускается молча: `set_issue_blocked` + alert (Telegram/Plane) с
|
||||
указанием цикла. Не блокировать поток других задач.
|
||||
|
||||
### 3.4 Видимость (B-4)
|
||||
- Заблокированная задача: Plane-статус `Blocked` (`set_issue_blocked`) и/или строка ожидания в
|
||||
Telegram-карточке («⏳ ждёт ORCH-NNN»). Использовать существующий механизм карточки
|
||||
(`notifications.update_task_tracker`), контракт never-raise / silent.
|
||||
- `reconciler` F-1 уже пропускает Blocked/Needs-Input (ORCH-060/068) — убедиться, что новые
|
||||
заблокированные-по-зависимости задачи тоже пропускаются (не «разблокируются» ошибочно).
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения API (endpoints)
|
||||
- **Новые HTTP endpoints не требуются.**
|
||||
- **Наблюдаемость:** расширить снимок `GET /queue` блоком о зависимостях/сериализации
|
||||
(по образцу блоков `reconcile` / `reaper` / `post_deploy` / `merge_verify`): кол-во
|
||||
заблокированных задач, держатель merge-lease, defer-счётчики, обнаруженные циклы. Read-only,
|
||||
никогда не источник истины для решений.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
- **КАНДИДАТ (если выбран БД/гибрид для Уровня B):** аддитивная таблица `job_deps` или колонка
|
||||
в `tasks` (см. §3.1). Только `CREATE TABLE IF NOT EXISTS` / `_ensure_column`. Без изменения
|
||||
существующих колонок `jobs`/`tasks`. Restart-safe, безопасно на общей прод-БД.
|
||||
- Уровень A (сериализация) — **без изменения схемы БД** (merge-lease файловый, как ORCH-065).
|
||||
|
||||
## 6. Требования к новым QG checks
|
||||
- **Новый зарегистрированный QG-чек НЕ вводится** (паттерн ORCH-071/058: под-гейт — врезка в
|
||||
`advance_stage` или расширение `check_branch_mergeable`, а не новая запись в `QG_CHECKS`).
|
||||
- Реестр `QG_CHECKS` — без изменений.
|
||||
|
||||
## 7. Конфигурация (`src/config.py`)
|
||||
Новые настройки по паттерну `*_enabled` (kill-switch) + `*_repos` (CSV scope, пусто →
|
||||
self-hosting). КАНДИДАТ-имена (финализирует архитектор):
|
||||
- Уровень A: `merge_serialize_enabled` / `merge_serialize_repos` (или расширение
|
||||
`merge_gate_*`); опционально `premerge_rebase_always` (вкл proactive rebase).
|
||||
- Уровень B: `task_deps_enabled` / `task_deps_source` (`plane|db|hybrid`).
|
||||
Дефолты — обратная совместимость (для не-self репо — прежнее поведение).
|
||||
|
||||
## 8. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR)
|
||||
- `06-adr/ADR-001-*.md` — решение по сериализации (A) и хранению зависимостей (B).
|
||||
- Обновить `docs/architecture/README.md` (раздел про очередь/merge-gate/сериализацию).
|
||||
- Обновить `CLAUDE.md` (паспорт: конвейер/инварианты, если меняется поведение очереди).
|
||||
- Обновить `CHANGELOG.md` (`## [Unreleased]`).
|
||||
- Если вводится таблица БД — отразить в `08-data-requirements.md` (создаёт архитектор).
|
||||
- `07-infra-requirements.md` — если требуется новый Plane-статус/настройка relations.
|
||||
|
||||
## 9. Инварианты (НЕ нарушать)
|
||||
1. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
|
||||
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
|
||||
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
|
||||
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
|
||||
4. never-raise во всех новых функциях; restart-safe состояние.
|
||||
5. ORCH-026 дополняет рубежи ORCH-073, не заменяет.
|
||||
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.
|
||||
107
docs/work-items/ORCH-026/03-acceptance-criteria.md
Normal file
107
docs/work-items/ORCH-026/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 03-Критерии приёмки — ORCH-026
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
|
||||
|
||||
Каждый критерий — проверяемое условие PASS/FAIL. Маппинг на тесты — `04-test-plan.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Уровень A — Сериализация merge/деплоя внутри одного репо
|
||||
|
||||
### AC-A1 — Сериализация merge внутри репо
|
||||
- **PASS:** пока задача A применимого репо удерживает окно merge (merge-lease не освобождён /
|
||||
`main` ещё не обновлён), задача B того же репо НЕ доходит до фактического merge — она
|
||||
**defer**-ится (повторная постановка через `available_at`), а не мержится от устаревшего `main`.
|
||||
- **FAIL:** B мержится/деплоится, пока A не в `main`; или B откатывается на `development` вместо
|
||||
defer.
|
||||
|
||||
### AC-A2 — Proactive pre-merge rebase
|
||||
- **PASS:** перед merge ветка задачи **всегда** догоняется на свежий `origin/main` (вызывается
|
||||
rebase), даже когда текстового конфликта нет и ветка формально не «behind» по старой проверке;
|
||||
после rebase ветка содержит код предшественника (A).
|
||||
- **FAIL:** rebase запускается только при конфликте/`branch_is_behind_main`, и B мержится без
|
||||
кода A.
|
||||
|
||||
### AC-A3 — Кросс-репо параллелизм сохранён
|
||||
- **PASS:** задача в `orchestrator` и задача в `enduro-trails` доходят до merge/деплоя
|
||||
параллельно — сериализация одного репо не блокирует другой (lease/гейт строго per-repo).
|
||||
- **FAIL:** задача одного репо ждёт освобождения ресурса, удерживаемого задачей ДРУГОГО репо.
|
||||
|
||||
### AC-A4 — Restart-safe
|
||||
- **PASS:** после рестарта прод-контейнера состояние сериализации восстанавливается корректно;
|
||||
мёртвый держатель merge-lease проактивно реклеймится (ORCH-065), конвейер не встаёт навсегда.
|
||||
- **FAIL:** рестарт оставляет навсегда захваченный lease → конвейер всех проектов встаёт.
|
||||
|
||||
### AC-A5 — Self-hosting safety
|
||||
- **PASS:** прод-контейнер orchestrator НЕ рестартится/не падает вне штатного `Confirm Deploy`
|
||||
(ORCH-059); нет push/force-push в `main`; `STAGE_TRANSITIONS` и реестр `QG_CHECKS` не изменены.
|
||||
- **FAIL:** любой незапрошенный рестарт прода, прямой push в `main`, или изменение машины стадий.
|
||||
|
||||
### AC-A6 — Anti-deadlock / anti-livelock при defer
|
||||
- **PASS:** при занятой сериализации B defer-ится с задержкой и bounded бюджетом; исчерпание
|
||||
бюджета → эскалация (alert/Blocked), не бесконечный цикл и не откат.
|
||||
- **FAIL:** B уходит в вечный defer-цикл, либо немедленно откатывается на `development`.
|
||||
|
||||
### AC-A7 — Условность (не-self репо без регресса)
|
||||
- **PASS:** при выключенном kill-switch и для репо вне scope поведение конвейера 1:1 как до
|
||||
ORCH-026 (нулевая регрессия для enduro-trails).
|
||||
- **FAIL:** не-self репо меняет поведение merge/деплоя.
|
||||
|
||||
---
|
||||
|
||||
## Уровень B — Декларативные зависимости
|
||||
|
||||
### AC-B1 — Декларация зависимости
|
||||
- **PASS:** задача может объявить `blocked-by`/`depends-on` (через выбранный источник —
|
||||
Plane relations / БД / гибрid), и связь корректно считывается планировщиком.
|
||||
- **FAIL:** связь не считывается / теряется.
|
||||
|
||||
### AC-B2 — Гейт планировщика (B не стартует до A)
|
||||
- **PASS:** задача с незавершённым depends-on **не клеймится** воркером (не запускается агент,
|
||||
слот `max_concurrency` не занимается), пока все depends-on не достигли `done`; как только A
|
||||
становится `done` — B становится claimable.
|
||||
- **FAIL:** B запускается раньше завершения A; или занимает слот, простаивая.
|
||||
|
||||
### AC-B3 — Детект дедлоков (циклы)
|
||||
- **PASS:** циклическая зависимость (A→B→A и длиннее) детектируется детерминированно; задача(и)
|
||||
в цикле → `Blocked` + alert (Telegram/Plane) с указанием цикла; поток остальных задач не
|
||||
блокируется.
|
||||
- **FAIL:** цикл приводит к молчаливому вечному ожиданию или к падению воркера.
|
||||
|
||||
### AC-B4 — Видимость заблокированной задачи
|
||||
- **PASS:** заблокированная задача видна — Plane-статус `Blocked` и/или строка ожидания в
|
||||
Telegram-карточке (что/кого ждёт); инвариант «одна карточка на задачу» сохранён.
|
||||
- **FAIL:** заблокированная задача невидима наблюдателю.
|
||||
|
||||
### AC-B5 — Совместимость с reconciler/reaper
|
||||
- **PASS:** `reconciler` F-1 НЕ «разблокирует» задачу, заблокированную по зависимости (как уже
|
||||
делает для Blocked/Needs-Input, ORCH-060/068); reaper не реапит корректно ожидающую задачу.
|
||||
- **FAIL:** reconciler продвигает заблокированную задачу мимо её depends-on.
|
||||
|
||||
---
|
||||
|
||||
## Общие (оба уровня)
|
||||
|
||||
### AC-G1 — never-raise
|
||||
- **PASS:** любая ошибка (git/сеть/БД/Plane) в новой логике не пробрасывается в `advance_stage`/
|
||||
воркер; деградирует консервативно (defer/skip/fail-closed), конвейер не падает.
|
||||
- **FAIL:** необработанное исключение роняет воркер/монитор-поток.
|
||||
|
||||
### AC-G2 — Kill-switch
|
||||
- **PASS:** глобальный kill-switch выключает фичу целиком → поведение 1:1 как до ORCH-026.
|
||||
- **FAIL:** при выключенном флаге поведение изменено.
|
||||
|
||||
### AC-G3 — Документация обновлена (golden source)
|
||||
- **PASS:** в ТОМ ЖЕ PR обновлены `docs/architecture/README.md`, `CLAUDE.md` (если изменилось
|
||||
поведение очереди), `CHANGELOG.md`, заведён ADR в `06-adr/`. Reviewer проверяет.
|
||||
- **FAIL:** код изменён, документация — нет (→ REQUEST_CHANGES).
|
||||
|
||||
### AC-G4 — Миграция БД безопасна (если применимо)
|
||||
- **PASS:** миграция только аддитивная (`CREATE TABLE IF NOT EXISTS`/`_ensure_column`),
|
||||
идемпотентна, безопасна на живой общей прод-БД; существующие данные enduro-trails не затронуты.
|
||||
- **FAIL:** деструктивная миграция / изменение существующих колонок.
|
||||
|
||||
### AC-G5 — Тесты зелёные
|
||||
- **PASS:** новые unit+integration тесты (`04-test-plan.yaml`) проходят; существующий
|
||||
`pytest tests/ -q` остаётся зелёным (нет регресса merge-gate/merge-verify/reconciler/reaper).
|
||||
- **FAIL:** красный pytest или регресс существующих тестов.
|
||||
169
docs/work-items/ORCH-026/04-test-plan.yaml
Normal file
169
docs/work-items/ORCH-026/04-test-plan.yaml
Normal file
@@ -0,0 +1,169 @@
|
||||
work_item: ORCH-026
|
||||
description: >
|
||||
План тестов для управления зависимостями задач (Уровень B) и сериализации
|
||||
merge/деплоя внутри одного репо (Уровень A). Стек: pytest. Имена модулей/функций —
|
||||
кандидаты; финализирует архитектор/разработчик. Все новые функции — never-raise.
|
||||
|
||||
tests:
|
||||
# ---------------- Уровень A: сериализация merge/деплоя ----------------
|
||||
- id: TC-A01
|
||||
type: unit
|
||||
description: >
|
||||
Proactive pre-merge rebase: ветка догоняется на свежий origin/main ДАЖЕ когда
|
||||
branch_is_behind_main вернул бы False (нет конфликта). Проверить, что rebase
|
||||
вызывается всегда перед merge (AC-A2).
|
||||
module: tests/test_orch026_premerge_rebase.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A02
|
||||
type: unit
|
||||
description: >
|
||||
Расширенное окно merge-lease: пока A держит lease (окно merge→main-updated),
|
||||
acquire для B того же репо возвращает busy → defer (не откат). holder-aware
|
||||
release не удаляет чужой lease (AC-A1, AC-A6).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A03
|
||||
type: unit
|
||||
description: >
|
||||
Сериализация строго per-repo: lease/гейт orchestrator не влияет на задачу
|
||||
enduro-trails — обе claimable параллельно (AC-A3).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A04
|
||||
type: unit
|
||||
description: >
|
||||
Restart-safe + проактивный реклейм: мёртвый держатель lease (pid не жив)
|
||||
реклеймится reclaim_stale_lease; конвейер не встаёт навсегда (AC-A4).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A05
|
||||
type: unit
|
||||
description: >
|
||||
Anti-livelock defer: B defer-ится с available_at-задержкой и bounded бюджетом;
|
||||
исчерпание → эскалация (Blocked/alert), не бесконечный цикл (AC-A6).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A06
|
||||
type: unit
|
||||
description: >
|
||||
Условность/kill-switch: при выключенном флаге и для репо вне scope поведение
|
||||
merge/деплоя 1:1 как до ORCH-026 — no-op (AC-A7, AC-G2).
|
||||
module: tests/test_orch026_conditionality.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A07
|
||||
type: unit
|
||||
description: >
|
||||
Self-hosting safety: новая логика никогда не делает push/force-push в main;
|
||||
force только --force-with-lease на ветку задачи; STAGE_TRANSITIONS не изменены
|
||||
(AC-A5).
|
||||
module: tests/test_orch026_conditionality.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A08
|
||||
type: integration
|
||||
description: >
|
||||
Сквозной сценарий: две задачи одного репо проходят deploy-staging→deploy; B не
|
||||
доходит до merge, пока A не в main; после A→done B ребейзится на свежий main
|
||||
(несёт код A) и мержится. main не теряет код A (AC-A1/AC-A2).
|
||||
module: tests/test_orch026_serialize_integration.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- Уровень B: декларативные зависимости ----------------
|
||||
- id: TC-B01
|
||||
type: unit
|
||||
description: >
|
||||
Чтение/декларация связи blocked-by из выбранного источника (Plane/БД/гибрид);
|
||||
связь корректно резолвится в depends_on_task_id (AC-B1). never-raise при
|
||||
недоступности источника → консервативно (нет связи или fail-closed по решению ADR).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B02
|
||||
type: unit
|
||||
description: >
|
||||
Гейт готовности (leaf-функция): задача с незавершённым depends-on НЕ ready;
|
||||
все depends-on в done → ready. Чистая логика, юнит-тестируемая (AC-B2).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B03
|
||||
type: unit
|
||||
description: >
|
||||
Детект циклов: A→B→A (и длиннее) детектируется детерминированно; ацикличный
|
||||
граф → циклов нет. Чистая функция (AC-B3).
|
||||
module: tests/test_orch026_dep_cycles.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B04
|
||||
type: unit
|
||||
description: >
|
||||
Цикл → set_issue_blocked + alert (Telegram/Plane), без падения воркера и без
|
||||
блокировки потока других задач (AC-B3, AC-G1).
|
||||
module: tests/test_orch026_dep_cycles.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B05
|
||||
type: unit
|
||||
description: >
|
||||
claim_next_job не клеймит заблокированную задачу (не занимает слот
|
||||
max_concurrency); как только depends-on done — задача становится claimable (AC-B2).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B06
|
||||
type: unit
|
||||
description: >
|
||||
Видимость: заблокированная задача отражается в Plane-статусе Blocked и/или
|
||||
строке ожидания Telegram-карточки; инвариант «одна карточка на задачу» сохранён
|
||||
(AC-B4). notifications never-raise / silent.
|
||||
module: tests/test_orch026_dep_visibility.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B07
|
||||
type: unit
|
||||
description: >
|
||||
reconciler F-1 НЕ разблокирует задачу, заблокированную по зависимости (как для
|
||||
Blocked/Needs-Input); reaper не реапит корректно ожидающую (AC-B5).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B08
|
||||
type: integration
|
||||
description: >
|
||||
Сквозной сценарий: B объявлена blocked-by A; при постановке в очередь B не
|
||||
стартует, пока A не done; после A→done воркер запускает B. Telegram/Plane
|
||||
показывают Blocked у B до разблокировки (AC-B1/B2/B4).
|
||||
module: tests/test_orch026_deps_integration.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- Общие / миграция / регресс ----------------
|
||||
- id: TC-G01
|
||||
type: unit
|
||||
description: >
|
||||
Аддитивная миграция БД (если выбран вариант с таблицей/колонкой): идемпотентна,
|
||||
безопасна на существующей БД с данными, не меняет существующие колонки (AC-G4).
|
||||
module: tests/test_orch026_migration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-G02
|
||||
type: unit
|
||||
description: >
|
||||
Наблюдаемость GET /queue: новый блок (заблокированные задачи / держатель lease /
|
||||
defer-счётчики / циклы) присутствует и read-only; не источник истины.
|
||||
module: tests/test_orch026_queue_observability.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-G03
|
||||
type: integration
|
||||
description: >
|
||||
Регресс: полный pytest tests/ -q остаётся зелёным — merge-gate (ORCH-043),
|
||||
merge-verify (ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не
|
||||
деградировали (AC-G5).
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,226 @@
|
||||
# ADR-001: Сериализация merge/деплоя внутри репо (A) + декларативные зависимости задач (B)
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator (self-hosting) · **Стадия:** architecture
|
||||
**Статус:** Accepted
|
||||
**Связи:** дополняет ORCH-043 (merge-gate), ORCH-065 (merge-lease + reclaim), ORCH-073/071
|
||||
(merge-verify, SHA-in-main), ORCH-1 (очередь). Глобальный ADR — `adr/adr-0015`.
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
ORCH-026 закрывает **первопричину** эрозии `main` 08.06 (некоординированный параллелизм
|
||||
задач одного репо: ветки от устаревшего `main`, фантом-merge затирает соседа) и попутно вводит
|
||||
исходный скоуп — декларативные зависимости задач (B ждёт A). Требования — `01-brd.md`,
|
||||
`02-trz.md`; PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
|
||||
Ключевое наблюдение архитектора: **бо́льшая часть инфраструктуры для Уровня A уже существует** и
|
||||
её НЕ нужно переписывать:
|
||||
|
||||
- **merge-lease** (ORCH-065, `src/merge_gate.py`): per-repo файловый лиз
|
||||
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный реклейм
|
||||
мёртвого/устаревшего держателя (`reclaim_stale_lease`, `pid_alive`). Restart-safe, per-repo.
|
||||
- **merge-gate** (ORCH-043, `check_branch_mergeable`): на ребре `deploy-staging → deploy`
|
||||
захватывает лиз, при необходимости ребейзит, держит лиз до фактического merge.
|
||||
- **defer-механизм** (`_handle_merge_gate_defer`): `merge-lock busy` → повторная постановка
|
||||
deployer'а через `available_at`, bounded `merge_defer_max_attempts` → эскалация (Blocked+alert).
|
||||
- **окно лиза** уже простирается от `deploy-staging → deploy` до release на одном из событий:
|
||||
PR-merged webhook (`gitea.py`), `deploy→done` (`stage_engine.py`), откат, проактивный реклейм.
|
||||
Для self-hosting `done` достигается ТОЛЬКО после `verify_merged_to_main` (SHA-in-main, ORCH-073).
|
||||
|
||||
Таким образом окно сериализации A-1 («merge → main-updated») **структурно уже реализовано**:
|
||||
пока A не подтверждена в `main` (для self — SHA-in-main → `done`), лиз держится, и B того же
|
||||
репо на своём merge-gate получает `merge-lock busy` → defer. Открытый вопрос BRD §4.3 (граница
|
||||
окна для self) решается так: **окно = от acquire до release; release-события не меняем**. Для
|
||||
non-self репо граница — PR-merged webhook; для self — `deploy→done` (= SHA-in-main подтверждён).
|
||||
|
||||
Что реально **отсутствует** для Уровня A:
|
||||
|
||||
- **A-2: безусловный proactive pre-merge rebase.** Сейчас `check_branch_mergeable` ребейзит
|
||||
ТОЛЬКО если `branch_is_behind_main` (⇔ `origin/main` не предок HEAD). AC-A2 требует, чтобы
|
||||
rebase вызывался **всегда** перед merge — детерминированный структурный анти-фантом на уровне
|
||||
планировщика, не зависящий от точности ancestor-проверки.
|
||||
|
||||
Для Уровня B инфраструктуры нет вовсе: очередь `jobs` (ORCH-1) плоская (FIFO по `id` +
|
||||
`available_at` + `max_concurrency`), выразить «B ждёт A» нельзя.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Уровень A — сериализация merge/деплоя (минимально-инвазивно, переиспользуя ORCH-043/065)
|
||||
|
||||
**A-1/A-3/A-4 (окно сериализации) — без изменений механизма.** Окно сериализации обеспечивается
|
||||
существующим merge-lease: захват в `check_branch_mergeable`, удержание до release. Подтверждаем и
|
||||
фиксируем в доке, что release-события (`PR-merged` / `deploy→done` / откат / `reclaim_stale_lease`)
|
||||
формируют окно «merge → main-updated». Кросс-репо параллелизм сохранён автоматически (лиз —
|
||||
per-repo файл). Restart-safe и анти-залипание — за счёт ORCH-065 reclaim. **Кода-изменений нет.**
|
||||
|
||||
**A-2 (безусловный pre-merge rebase) — новое поведение, флаг `premerge_rebase_always`.**
|
||||
|
||||
- В `check_branch_mergeable` (`src/qg/checks.py`), ПОД захваченным merge-lease: когда
|
||||
`settings.premerge_rebase_always` истинно (и merge-gate применим к репо), **пропустить
|
||||
short-circuit `branch_is_behind_main`** и **всегда** вызвать `merge_gate.auto_rebase_onto_main`.
|
||||
- `auto_rebase_onto_main` уже идемпотентен и дёшев на актуальной ветке: `git rebase origin/main`
|
||||
на не-отстающей ветке — no-op (rc 0, HEAD не меняется), последующий `push --force-with-lease`
|
||||
→ «Everything up-to-date» (тот же SHA, **CI не перезапускается, лишних коммитов нет**). На
|
||||
отстающей ветке — реальный догон. Текстовый конфликт → существующий контракт: `rebase --abort`
|
||||
→ откат на `development` (как ORCH-043). **Инвариант: никаких push/force-push в `main`** —
|
||||
единственная force-операция остаётся `--force-with-lease` на ветку задачи.
|
||||
- Когда флаг выключен → прежнее поведение (ребейз только при `branch_is_behind_main`),
|
||||
обратная совместимость 1:1 (AC-A7/AC-G2).
|
||||
- **Скоуп — общий с merge-gate:** реально только для `merge_gate_repos` (пусто → self-hosting
|
||||
`orchestrator`). Никакого нового scope-флага.
|
||||
|
||||
**A-5/A-6 (safety, anti-livelock) — без изменений.** `STAGE_TRANSITIONS`, `QG_CHECKS`,
|
||||
`Confirm Deploy` (ORCH-059), exit-коды хука, terminal-sync не трогаются. defer-бюджет —
|
||||
существующий `merge_defer_max_attempts` → Blocked+alert при исчерпании. Прод-контейнер не
|
||||
рестартится вне штатного `Confirm Deploy`.
|
||||
|
||||
### Уровень B — декларативные зависимости (новая инфраструктура)
|
||||
|
||||
**B-источник: гибрид с БД как источником истины для планировщика; флаг `task_deps_source`.**
|
||||
|
||||
Планировщик `claim_next_job` — горячий цикл, обслуживающий очередь ВСЕХ проектов из ОДНОГО
|
||||
инстанса. Он **обязан** быть offline-устойчивым и быстрым: сетевой запрос в Plane на каждый claim
|
||||
= при недоступности Plane встанет конвейер всех проектов (нарушение self-hosting safety). Поэтому:
|
||||
|
||||
- **Авторитетный для планировщика стор — локальная БД**, новая аддитивная таблица
|
||||
`job_deps(task_id, depends_on_task_id, created_at)` (детали — `08-data-requirements.md`).
|
||||
Связь хранится по `tasks.id` (стабильный локальный ключ). Зависимости — **только внутри одного
|
||||
репо** (v1; кросс-репо — non-goal, BRD §5).
|
||||
- **`task_deps_source = db | plane | hybrid`** (дефолт **`db`**): `db` — связи пишутся напрямую в
|
||||
`job_deps` (потребитель — декомпозиция эпиков ORCH-025); `plane` — связи читаются из Plane
|
||||
relations в `handle_work_item_created` и **кэшируются** в `job_deps`; `hybrid` — Plane как
|
||||
декларация + БД-кэш. Plane-ingestion — тонкий add-on за флагом; планировщик ВСЕГДА читает БД.
|
||||
|
||||
**B-2 (гейт планировщика) — SQL `NOT EXISTS`, без занятия слота `max_concurrency`.**
|
||||
|
||||
Гейт готовности выражается декларативно в `claim_next_job` (`src/db.py`): задача claimable, если
|
||||
у неё нет ни одной незавершённой зависимости. Когда `settings.task_deps_enabled` — к существующему
|
||||
SELECT добавляется условие:
|
||||
|
||||
```sql
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM job_deps d
|
||||
JOIN tasks t ON t.id = d.depends_on_task_id
|
||||
WHERE d.task_id = j.task_id AND t.stage != 'done'
|
||||
)
|
||||
```
|
||||
|
||||
Это: (1) **не занимает слот** — job просто не выбирается, агент не запускается (AC-B2);
|
||||
(2) restart-safe (чистая БД); (3) never-raise (это SQL); (4) при пустой `job_deps` —
|
||||
инертно (нулевая регрессия, AC-G2); (5) при выключенном `task_deps_enabled` условие НЕ
|
||||
добавляется → запрос 1:1 как в ORCH-1. Как только все зависимости достигают `stage='done'`,
|
||||
задача автоматически становится claimable.
|
||||
|
||||
Чистая leaf-логика «готова ли задача» выносится в новый модуль `src/task_deps.py`:
|
||||
`is_task_ready(task_id) -> (bool, waiting_on: list[str])` (never-raise) — для реконсилятора,
|
||||
карточки и `/queue` (SQL в `claim_next_job` — горячий путь, дублирует ту же семантику).
|
||||
|
||||
**B-3 (детект дедлоков) — DFS, чистая функция.**
|
||||
|
||||
`task_deps.detect_cycle(task_id) -> list[int] | None` — обход графа `job_deps` (внутри репо),
|
||||
детерминированный, юнит-тестируемый, never-raise. Запускается: (1) при вставке связи
|
||||
(`add_dependency`) — цикл отклоняется/алертится сразу (лучший UX); (2) backstop-проход в тике
|
||||
`reconciler` (на случай связей, добавленных в обход). Цикл → `set_issue_blocked(work_item_id)` +
|
||||
Telegram/Plane alert с перечислением цикла. SQL-гейт B-2 сам по себе никогда не выберет задачу в
|
||||
цикле (её зависимости не достигнут `done`) — детектор делает это **видимым**, а не молчаливым
|
||||
вечным ожиданием (AC-B3). Поток остальных задач не блокируется.
|
||||
|
||||
**B-4 (видимость).**
|
||||
|
||||
- Нормальное ожидание (B ждёт A, A в работе — транзиентно и ожидаемо): строка в Telegram-карточке
|
||||
«⏳ ждёт ORCH-NNN» через `notifications.update_task_tracker`, never-raise/silent. **Plane Blocked
|
||||
при нормальном ожидании НЕ ставим** — иначе флаппинг Blocked на каждом коротком ожидании.
|
||||
- Дедлок/цикл (B-3): `set_issue_blocked` (Plane `Blocked`) + alert. Это «и/или» из AC-B4.
|
||||
- Инвариант «одна карточка на задачу» сохранён (ORCH-042/067).
|
||||
|
||||
**B-5 (совместимость reconciler/reaper).**
|
||||
|
||||
- `reconciler` F-1 не должен «разблокировать» dep-заблокированную задачу мимо её зависимостей.
|
||||
В фильтр пригодности reconciler добавляется проверка `task_deps.is_task_ready` (по образцу
|
||||
`reconcile_skip_blocked_enabled`, ORCH-060): не готова → skip.
|
||||
- `reaper` сканирует **`running`** jobs; dep-заблокированный job остаётся `queued` (его не
|
||||
клеймят) → reaper его не трогает по построению. Фиксируем в доке.
|
||||
|
||||
**Наблюдаемость (TRZ §4):** блок `task_deps` в снимке `GET /queue` (read-only, по образцу
|
||||
`reconcile`/`reaper`): кол-во заблокированных задач, держатель merge-lease, defer-счётчики,
|
||||
обнаруженные циклы. Никогда не источник решений.
|
||||
|
||||
### Конфигурация (`src/config.py`)
|
||||
|
||||
| Флаг | Дефолт | Назначение |
|
||||
|------|--------|-----------|
|
||||
| `premerge_rebase_always` | `True` | Уровень A: безусловный pre-merge rebase под лизом. Скоуп — `merge_gate_repos`. Kill-switch (`False` → ребейз только при behind, как ORCH-043). |
|
||||
| `task_deps_enabled` | `True` | Уровень B: глобальный kill-switch гейта зависимостей. `False` → `claim_next_job` 1:1 как ORCH-1. Инертно при пустой `job_deps`. |
|
||||
| `task_deps_source` | `"db"` | Источник деклараций: `db`\|`plane`\|`hybrid`. Планировщик всегда читает БД-кэш. |
|
||||
|
||||
Дефолты следуют конвенции репо (`*_enabled=True` + kill-switch), при этом обе фичи инертны без
|
||||
данных (нет деклараций / нет применимых репо) → нулевая регрессия для enduro-trails.
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (и почему отвергнуты)
|
||||
|
||||
1. **Уровень A — отдельный глобальный per-repo merge-lock или FIFO merge-queue.** Дублировал бы
|
||||
уже существующий merge-lease (ORCH-065), вводил второй механизм сериализации с риском
|
||||
рассинхрона. Отвергнуто: BRD §4.2 требует минимально-инвазивного решения, переиспользующего
|
||||
ORCH-065/043. Окно лиза уже даёт сериализацию.
|
||||
|
||||
2. **Уровень A — расширять release-точки лиза (держать до отдельного `main-updated`-события).**
|
||||
Не требуется: для self `done` ⇔ SHA-in-main (ORCH-073), для non-self — PR-merged webhook;
|
||||
окно уже корректно. Доп. событие усложнило бы reclaim без выигрыша.
|
||||
|
||||
3. **Уровень B — Plane relations как источник истины планировщика.** Сетевой запрос в горячем
|
||||
цикле claim; при недоступности Plane встаёт очередь всех проектов (self-hosting risk).
|
||||
Отвергнуто; Plane оставлен опциональным источником **декларации** (`task_deps_source=plane`),
|
||||
но планировщик читает только БД-кэш.
|
||||
|
||||
4. **Уровень B — гейт зависимостей в воркере (`_drain_once`) поверх `claim_next_job`.** Пришлось
|
||||
бы клеймить job, обнаруживать незавершённую зависимость и re-queue’ить — churn, расход attempts,
|
||||
гонки. SQL `NOT EXISTS` в самом `claim_next_job` чище: job просто не выбирается, слот свободен.
|
||||
|
||||
5. **Уровень B — поле/JSON в `tasks` вместо таблицы.** Таблица `job_deps` нормальна (M:N),
|
||||
индексируема, проще для DFS и `NOT EXISTS`. Поле в `tasks` потребовало бы парсинг-логики.
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы.**
|
||||
- Минимально-инвазивно: Уровень A — один флаг + снятие short-circuit; окно сериализации не
|
||||
переписывается. Переиспользует ORCH-043/065 целиком.
|
||||
- Уровень B — одно `NOT EXISTS` в `claim_next_job` + аддитивная таблица + leaf-модуль
|
||||
`task_deps.py`; `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки ORCH-071/058).
|
||||
- Обе фичи инертны без данных → нулевая регрессия для enduro-trails (AC-A7/AC-G2).
|
||||
- restart-safe (БД + файловый лиз), never-raise, kill-switch на каждую фичу.
|
||||
|
||||
**Минусы / ограничения.**
|
||||
- `premerge_rebase_always=True` добавляет (дешёвый, no-op на актуальной ветке) `rebase`+`push`
|
||||
на каждый self-merge. Цена — лишний git-вызов; компенсируется детерминизмом анти-фантома.
|
||||
- Уровень B v1 — только intra-repo зависимости; кросс-репо — follow-up (non-goal).
|
||||
- Гейт B-2 в `claim_next_job` слегка усложняет горячий SQL (один `NOT EXISTS`); защищён
|
||||
kill-switch и инертностью при пустой таблице.
|
||||
- `task_deps.py` цикл-детектор — новая поверхность; покрывается юнит-тестами (`04-test-plan.yaml`).
|
||||
|
||||
**Инварианты (не нарушать).**
|
||||
1. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
|
||||
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
|
||||
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
|
||||
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
|
||||
4. never-raise во всех новых функциях; restart-safe состояние; миграция БД только аддитивная.
|
||||
5. ORCH-026 **дополняет** рубежи ORCH-073, не заменяет.
|
||||
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.
|
||||
|
||||
**Места реализации (для developer).**
|
||||
- `src/qg/checks.py::check_branch_mergeable` — ветка `premerge_rebase_always`.
|
||||
- `src/db.py::claim_next_job` — условный `NOT EXISTS`-гейт; новые helpers `add_dependency`,
|
||||
`get_dependencies`, `job_deps` миграция в `init_db` (`CREATE TABLE IF NOT EXISTS`).
|
||||
- `src/task_deps.py` (новый, leaf) — `is_task_ready`, `detect_cycle`, snapshot для `/queue`.
|
||||
- `src/webhooks/plane.py::handle_work_item_created` — ingestion Plane relations (за `task_deps_source`).
|
||||
- `src/reconciler.py` — skip dep-заблокированных + backstop цикл-детект.
|
||||
- `src/notifications.py` — строка ожидания в карточке.
|
||||
- `src/config.py` — `premerge_rebase_always`, `task_deps_enabled`, `task_deps_source`.
|
||||
- Документация: `docs/architecture/README.md`, `CLAUDE.md` (если меняется поведение очереди),
|
||||
`CHANGELOG.md`, глобальный `adr/adr-0015`.
|
||||
65
docs/work-items/ORCH-026/08-data-requirements.md
Normal file
65
docs/work-items/ORCH-026/08-data-requirements.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 08 — Требования к схеме БД — ORCH-026
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
|
||||
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md` (Уровень B).
|
||||
|
||||
> Уровень A (сериализация merge/деплоя) — **БЕЗ изменения схемы БД** (merge-lease файловый,
|
||||
> `.merge-lease-<repo>.json`, ORCH-065). Изменения схемы касаются ТОЛЬКО Уровня B.
|
||||
|
||||
---
|
||||
|
||||
## Новая таблица `job_deps` (аддитивная)
|
||||
|
||||
Хранит декларативные зависимости «задача `task_id` ждёт задачу `depends_on_task_id`».
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS job_deps (
|
||||
task_id INTEGER NOT NULL, -- tasks.id зависимой задачи (B)
|
||||
depends_on_task_id INTEGER NOT NULL, -- tasks.id задачи-предшественника (A)
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (task_id, depends_on_task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
|
||||
```
|
||||
|
||||
### Поля
|
||||
| Поле | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `task_id` | INTEGER | `tasks.id` зависимой задачи (B). Не запускается, пока зависимости не `done`. |
|
||||
| `depends_on_task_id` | INTEGER | `tasks.id` предшественника (A). Терминальность — `tasks.stage = 'done'`. |
|
||||
| `created_at` | TEXT | Время декларации (диагностика). |
|
||||
|
||||
### Ключ и индексы
|
||||
- **PK `(task_id, depends_on_task_id)`** — идемпотентность вставки (повторная декларация связи —
|
||||
no-op через `INSERT OR IGNORE`), запрет дублей.
|
||||
- `idx_job_deps_task` — гейт планировщика (`NOT EXISTS ... WHERE d.task_id = j.task_id`).
|
||||
- `idx_job_deps_depends` — обратные рёбра для DFS цикл-детектора.
|
||||
|
||||
### Семантика готовности (источник истины планировщика)
|
||||
Задача `task_id` **готова к запуску** ⇔ нет ни одной строки `job_deps` для неё, чей
|
||||
`depends_on_task_id` указывает на задачу с `tasks.stage != 'done'`. Терминал — только `done`
|
||||
(совпадает с тем, как `get_active_tasks_for_reconcile` трактует терминальность).
|
||||
|
||||
### Связь по `task_id`, а не `work_item_id`
|
||||
`tasks.id` — стабильный локальный автоинкремент-ключ; `work_item_id`/`plane_id` могут
|
||||
ресолвиться/коллизиться (см. `ensure_unique_work_item_id`). FK логический (без `REFERENCES`,
|
||||
как у `jobs.task_id`) — не блокирует аддитивную миграцию и удаление строк tasks (которого в
|
||||
конвейере нет). Зависимости — **только intra-repo** (v1); кросс-репо рёбра не создаются.
|
||||
|
||||
---
|
||||
|
||||
## Миграция (AC-G4)
|
||||
|
||||
- Выполняется в `src/db.py::init_db` рядом с прочими: **только** `CREATE TABLE IF NOT EXISTS` +
|
||||
`CREATE INDEX IF NOT EXISTS`. **Идемпотентно**, restart-safe, безопасно на живой общей прод-БД.
|
||||
- **Существующие колонки/таблицы (`jobs`, `tasks`, `agent_runs`, `events`) НЕ изменяются** →
|
||||
данные enduro-trails не затронуты.
|
||||
- Откат фичи — флагом `task_deps_enabled=False` (таблица остаётся, гейт не применяется); сама
|
||||
таблица деструктивно не удаляется.
|
||||
|
||||
## Что НЕ меняется
|
||||
- Схема `jobs` (включая `available_at`, `pid`, `attempts`/`transient_attempts`) — без изменений;
|
||||
defer Уровня A/B переиспользует существующий `available_at`-механизм.
|
||||
- Схема `tasks` — без изменений (видимость через существующие `tracker_message_id` и Plane Blocked).
|
||||
- merge-lease — файловый, вне БД.
|
||||
17
docs/work-items/ORCH-026/10-tech-risks.md
Normal file
17
docs/work-items/ORCH-026/10-tech-risks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 10 — Технические риски — ORCH-026
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
|
||||
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md`.
|
||||
|
||||
| # | Риск | Уровень | Митигация |
|
||||
|---|------|---------|-----------|
|
||||
| R-1 | **Гейт `NOT EXISTS` в `claim_next_job` (горячий путь всех проектов) содержит баг → встаёт очередь ВСЕХ проектов** (self-hosting групповой риск). | Высокий | Условие добавляется ТОЛЬКО при `task_deps_enabled`; инертно при пустой `job_deps` (нулевая регрессия); kill-switch `task_deps_enabled=False` мгновенно возвращает поведение ORCH-1; интеграционный тест «пустые deps ⇒ FIFO 1:1» (AC-G2). |
|
||||
| R-2 | **Безусловный `premerge_rebase_always` делает лишний `push --force-with-lease` → ложный перезапуск CI / новые коммиты.** | Низкий | На актуальной ветке `rebase origin/main` — no-op (HEAD не меняется), push → «Everything up-to-date» (тот же SHA, CI не триггерится). Подтвердить тестом, что SHA не меняется на уже-актуальной ветке. |
|
||||
| R-3 | **Дедлок по циклической зависимости → задача молча ждёт вечно.** | Средний | DFS-детектор `detect_cycle` при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert с перечислением цикла (AC-B3); SQL-гейт не выбирает задачу в цикле, детектор делает это видимым. |
|
||||
| R-4 | **Livelock: B бесконечно defer’ится на `merge-lock busy`.** | Низкий | Существующий bounded-бюджет `merge_defer_max_attempts` → Blocked+alert (ORCH-043, без изменений). |
|
||||
| R-5 | **Залипший merge-lease после смерти держателя → конвейер репо встаёт навсегда.** | Средний | Переиспользуется ORCH-065: `reclaim_stale_lease` (мёртвый `pid` / TTL `merge_lock_timeout_s`) + holder-aware release. Restart-safe (AC-A4). |
|
||||
| R-6 | **Plane relations недоступны/неверно смаплены при `task_deps_source=plane`.** | Средний | Планировщик читает ТОЛЬКО БД-кэш `job_deps`; Plane-ingestion — best-effort, never-raise; дефолт `task_deps_source=db` не зависит от Plane. |
|
||||
| R-7 | **reconciler «разблокирует» dep-заблокированную задачу мимо её зависимостей.** | Средний | В фильтр reconciler добавляется `is_task_ready` (паттерн ORCH-060 skip-Blocked); reaper трогает только `running` — dep-блок остаётся `queued` (AC-B5). |
|
||||
| R-8 | **Миграция БД повреждает общую прод-БД (данные enduro-trails).** | Низкий | Только аддитивно: `CREATE TABLE/INDEX IF NOT EXISTS`; существующие колонки не меняются; идемпотентно (AC-G4). |
|
||||
| R-9 | **Self-hosting: изменения требуют рестарта прод-контейнера вне `Confirm Deploy`.** | Высокий (если нарушено) | Все изменения — обычный код, проходят `deploy-staging` (8501) → `Confirm Deploy` (ORCH-059). `STAGE_TRANSITIONS`/`QG_CHECKS` не трогаются; никакого внеочередного рестарта (AC-A5). |
|
||||
| R-10 | **Конфликт точек интеграции A (merge-gate) и B (постановка в очередь).** | Низкий | Разные точки конвейера: B гейтит claim job (вход), A гейтит merge на ребре `deploy-staging→deploy`. Независимы; покрыть интеграционным тестом совместной работы (BRD §4.4). |
|
||||
47
docs/work-items/ORCH-026/12-review.md
Normal file
47
docs/work-items/ORCH-026/12-review.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-026
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-026
|
||||
|
||||
## Summary
|
||||
ORCH-026 реализует два уровня по ADR-001: **Уровень A** — сериализация merge/deploy внутри одного репо (переиспользует merge-lease ORCH-043/065 + единственная новая логика — безусловный pre-merge rebase под флагом `premerge_rebase_always`) и **Уровень B** — декларативные зависимости задач (аддитивная таблица `job_deps`, гейт `NOT EXISTS` в `claim_next_job`, leaf-модуль `src/task_deps.py`). Реализация минимально-инвазивна, строго соответствует ТЗ и ADR, обе фичи условны (kill-switch) и инертны без данных. Все 16 критериев приёмки выполнены. Полный прогон `pytest tests/ -q` — **991 passed**, из них 50 новых ORCH-026-тестов зелёные. Документация обновлена в том же PR. **APPROVED.**
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
### P3 — Nice to have
|
||||
- [ ] PR-ветка несёт коммиты ORCH-073 (`main` ещё не получил merge #77, merge-base = `77abfb3`). Это ожидаемо по топологии (ORCH-026 (B) построен поверх уже отревьюенного предшественника ORCH-073 (A): у ORCH-073 есть собственные `12-review.md`/`13-test-report.md`/`14-deploy-log.md`) и фактически демонстрирует саму фичу A (rebase B на код A). Не блокирует; при merge в `main` приедут оба набора изменений — это корректно.
|
||||
|
||||
## Соответствие ТЗ и ADR
|
||||
- **Уровень A (AC-A1…A7):** окно сериализации обеспечено существующим merge-lease без нового механизма (ADR §A-1/A-3/A-4). A-2 — `check_branch_mergeable` (`src/qg/checks.py`) под лизом при `premerge_rebase_always=True` всегда вызывает `auto_rebase_onto_main`, снимая short-circuit `branch_is_behind_main`; kill-switch off → поведение ORCH-043 1:1. `STAGE_TRANSITIONS`/`QG_CHECKS`/`Confirm Deploy` не тронуты — соответствует инвариантам §9. Никаких push/force в `main` (только `--force-with-lease` ветки).
|
||||
- **Уровень B (AC-B1…B5):** гейт `NOT EXISTS (job_deps JOIN tasks WHERE stage!='done')` в `claim_next_job` (`src/db.py`) — job не выбирается, слот `max_concurrency` не занимается; при выключенном флаге / пустой таблице clause не добавляется (нулевая регрессия). `task_deps.py` — чистый leaf: `is_task_ready` (fail-open), итеративный WHITE/GREY/BLACK DFS-детектор циклов (защита от recursion-limit на проде), `handle_cycle` (Blocked+alert), `declare_dependency`, `ingest_plane_relations` (только `plane|hybrid`, дефолт `db` не ходит в сеть на горячем пути). reconciler F-1 получил Guard 3 (skip dep-заблокированных + backstop детект цикла); reaper не тронут (сканирует `running`).
|
||||
- **Общие (AC-G1…G5):** контракт never-raise выдержан во всех новых функциях (try/except, консервативная деградация). Миграция строго аддитивна — `CREATE TABLE/INDEX IF NOT EXISTS`, без `REFERENCES`, схема `tasks`/`jobs` не изменена (AC-G4 OK на живой общей БД). Наблюдаемость — read-only блок `task_deps` в `GET /queue`. Реализация в точности по местам, указанным в ADR §«Места реализации».
|
||||
|
||||
## Качество кода
|
||||
- Docstrings на всех публичных функциях, явно документирован контракт fail-open/fail-closed.
|
||||
- SQL-гейт безопасен: `dep_gate` — константная строка (нет инъекции), таблица `job_deps` гарантированно создана в `init_db`.
|
||||
- Переменные `plane_id`/`plane_project_id`/`task_id` в `start_pipeline` — в области видимости (проверено).
|
||||
- Тесты содержательные: миграция, conditionality (kill-switch), циклы, видимость, observability, интеграция сериализации и зависимостей.
|
||||
|
||||
## Документация — обновлена (golden source)
|
||||
Проверено: код в `src/` изменён → документация обновлена В ТОМ ЖЕ PR (разнесена по pipeline-коммитам ветки, что нормально):
|
||||
- `docs/architecture/README.md` — разделы про очередь (`claim_next_job`-гейт), pre-merge rebase, «Зависимости задач: B ждёт A», `job_deps`, наблюдаемость (architect-коммит `f8ec1c2`). ✓
|
||||
- `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md` + глобальный `docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md`. ✓
|
||||
- `CLAUDE.md` — паспорт (очередь/сериализация). ✓
|
||||
- `CHANGELOG.md` — запись `## [Unreleased]`. ✓
|
||||
- `.env.example` — `ORCH_PREMERGE_REBASE_ALWAYS`/`ORCH_TASK_DEPS_ENABLED`/`ORCH_TASK_DEPS_SOURCE`. ✓
|
||||
- `08-data-requirements.md` — таблица `job_deps`. ✓
|
||||
|
||||
Документация = golden source: требование выполнено.
|
||||
75
docs/work-items/ORCH-026/13-test-report.md
Normal file
75
docs/work-items/ORCH-026/13-test-report.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-026
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-026
|
||||
|
||||
Задача: «Управление зависимостями задач (B ждёт A) в очереди» + сериализация merge/деплоя
|
||||
одного репо. Ветка `feature/ORCH-026-b-a`. Review-вердикт: **APPROVED** (`12-review.md`).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: `feature/ORCH-026-b-a` (HEAD `aaa4829`)
|
||||
- Прод-оркестратор (8500): `/health` → `{"status":"ok"}` (не перезапускался, self-hosting инвариант соблюдён)
|
||||
- Дата: 2026-06-08
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
### Уровень A — сериализация merge/деплоя
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-A01 | Proactive pre-merge rebase (всегда, даже когда не behind) | `test_orch026_premerge_rebase::test_always_rebases_even_when_not_behind` | PASS |
|
||||
| TC-A02 | Расширенное окно merge-lease, defer не откат; holder-aware release | `test_orch026_merge_serialize::test_second_task_same_repo_defers_not_rollback`, `test_holder_aware_release_keeps_foreign_lease` | PASS |
|
||||
| TC-A03 | Сериализация строго per-repo (orchestrator ≠ enduro-trails) | `test_orch026_merge_serialize::test_serialization_is_strictly_per_repo` | PASS |
|
||||
| TC-A04 | Restart-safe + реклейм мёртвого держателя lease | `test_orch026_merge_serialize::test_dead_holder_lease_is_reclaimed`, `test_stale_lease_age_reclaimed_on_acquire` | PASS |
|
||||
| TC-A05 | Anti-livelock defer: bounded бюджет, эскалация | `test_orch026_merge_serialize::test_defer_budget_is_bounded` | PASS |
|
||||
| TC-A06 | Условность/kill-switch: off + out-of-scope = no-op | `test_orch026_conditionality::test_out_of_scope_repo_is_noop_even_with_flag_on`, `test_premerge_rebase::test_flag_off_short_circuits_like_orch043` | PASS |
|
||||
| TC-A07 | Self-hosting safety: только `--force-with-lease` на ветку, STAGE_TRANSITIONS не тронуты | `test_orch026_conditionality::test_premerge_only_force_with_lease_on_branch`, `test_stage_transitions_unchanged` | PASS |
|
||||
| TC-A08 | Сквозной сценарий сериализации merge-окна | `test_orch026_serialize_integration::test_serialized_merge_window` | PASS |
|
||||
|
||||
### Уровень B — декларативные зависимости
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-B01 | Декларация/резолв blocked-by; never-raise при недоступности | `test_orch026_task_deps::test_add_dependency_declares_and_resolves`, `test_add_dependency_never_raises_on_bad_input` | PASS |
|
||||
| TC-B02 | Гейт готовности: незавершённый depends-on → не ready; все done → ready | `test_orch026_task_deps::test_is_task_ready_blocked_then_ready`, `test_is_task_ready_no_deps_is_ready` | PASS |
|
||||
| TC-B03 | Детект циклов A→B→A и длиннее; ацикличный → нет | `test_orch026_dep_cycles::test_detect_two_node_cycle`, `test_detect_longer_cycle`, `test_acyclic_graph_has_no_cycle`, `test_detect_cycle_never_raises_on_garbage` | PASS |
|
||||
| TC-B04 | Цикл → Blocked + alert без падения воркера | `test_orch026_dep_cycles::test_handle_cycle_blocks_and_alerts`, `test_handle_cycle_never_raises_when_notify_fails` | PASS |
|
||||
| TC-B05 | claim_next_job не клеймит заблокированную (слот свободен), разблокируется при done | `test_orch026_task_deps::test_claim_skips_dep_blocked_job`, `test_claim_prefers_unblocked_job_over_blocked` | PASS |
|
||||
| TC-B06 | Видимость: строка ожидания в карточке; never-raise рендер | `test_orch026_dep_visibility::test_blocked_task_shows_waiting_line`, `test_render_never_raises_on_dep_error` | PASS |
|
||||
| TC-B07 | reconciler F-1 не разблокирует dep-заблокированную | `test_orch026_task_deps::test_reconciler_skip_helper_honours_block` | PASS |
|
||||
| TC-B08 | Сквозной: B стартует только после A→done; multiple predecessors | `test_orch026_deps_integration::test_b_waits_for_a_then_runs`, `test_multiple_predecessors_all_must_be_done`, `test_ingest_plane_relations_writes_db` | PASS |
|
||||
|
||||
### Общие / миграция / регресс
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-G01 | Аддитивная миграция job_deps: идемпотентна, данные сохранены | `test_orch026_migration::test_job_deps_table_created`, `test_job_deps_indices_created`, `test_migration_idempotent_and_preserves_data` | PASS |
|
||||
| TC-G02 | Наблюдаемость GET /queue: read-only блок task_deps | `test_orch026_queue_observability::test_queue_endpoint_includes_task_deps`, `test_snapshot_*` | PASS |
|
||||
| TC-G03 | Регресс: полный pytest зелёный | `tests/` (991 passed) | PASS |
|
||||
|
||||
## Smoke test API (прод 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → активные задачи отдаются, ORCH-026 (id 58) в стадии `testing` — OK
|
||||
- `GET /queue` → counts/resilience/reconcile/reaper/merge_verify читаются; брейкер `closed`, preflight OK — OK
|
||||
- Примечание: блок `task_deps` в `/queue` прода 8500 ОТСУТСТВУЕТ — ожидаемо: прод-контейнер несёт текущую задеплоенную версию, ORCH-026 ещё не выкатан (self-hosting, деплой на поздних стадиях). Фича наблюдаемости верифицирована in-branch тестом `test_queue_endpoint_includes_task_deps` (PASS) через TestClient на коде ветки.
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
tests/test_orch026_*.py — 50 passed, 1 warning in 1.56s
|
||||
tests/ — 991 passed, 1 warning in 26.52s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, предсуществующий, не относится к ORCH-026)
|
||||
|
||||
## Покрытие критериев приёмки (03-acceptance-criteria.md)
|
||||
Все 16 критериев (AC-A1…A7, AC-B1…B5, AC-G1…G5) покрыты прохождением соответствующих TC и
|
||||
подтверждены review-вердиктом APPROVED. Регрессии merge-gate (ORCH-043), merge-verify
|
||||
(ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не обнаружено.
|
||||
|
||||
## Итог
|
||||
**PASS** — 50/50 новых ORCH-026-тестов зелёные, полный регресс 991 passed, smoke API OK,
|
||||
прод-контейнер не затронут. Задача готова к переходу на `deploy-staging`.
|
||||
12
docs/work-items/ORCH-026/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-026/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-026
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
34
docs/work-items/ORCH-026/15-staging-log.md
Normal file
34
docs/work-items/ORCH-026/15-staging-log.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T16:14:11+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. Exit code 0 → advance.
|
||||
|
||||
Canonical run (ORCH-048, ADR-001) inside the live staging container:
|
||||
|
||||
```
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
## Result: 8/10 checks PASS
|
||||
|
||||
- **Block A (SMOKE):** A1 /health, A2 /queue, A3 ORCH_STAGING=true — all PASS.
|
||||
- **Block B (ACCESS):** B4 Plane sandbox (R), B5 Gitea orchestrator-sandbox (R+push), B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
|
||||
- **Block C (E2E, stub):** C7 create issue, C8 trigger pipeline — PASS.
|
||||
|
||||
REAL failed: **none** — all pipeline checks green.
|
||||
|
||||
## INFRA-WAIVED (ORCH-061)
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
C9a/C9b are the two known sandbox-infra-only checks (depend on SANDBOX bot accounts being members of the sandbox Plane project, not on the pipeline). They are tolerated because every REAL check is green; the script printed `INFRA-WAIVED:` and exited 0 (fail-closed semantics preserved: any REAL failure would still yield exit 1).
|
||||
14
docs/work-items/ORCH-026/16-post-deploy-log.md
Normal file
14
docs/work-items/ORCH-026/16-post-deploy-log.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
post_deploy_status: HEALTHY
|
||||
action_taken: NONE
|
||||
work_item: ORCH-026
|
||||
window_s: 900
|
||||
checks_total: 30
|
||||
checks_failed: 0
|
||||
---
|
||||
|
||||
# Post-deploy log — ORCH-021 post-deploy monitor
|
||||
|
||||
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
|
||||
|
||||
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.
|
||||
7
docs/work-items/ORCH-069/00-business-request.md
Normal file
7
docs/work-items/ORCH-069/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200)
|
||||
|
||||
Work Item ID: ORCH-069
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
76
docs/work-items/ORCH-069/01-brd.md
Normal file
76
docs/work-items/ORCH-069/01-brd.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# BRD — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200)
|
||||
|
||||
Work Item ID: ORCH-069
|
||||
Тип: Enhancement (QoL / конфигурируемость)
|
||||
Источник: Слава, 2026-06-08
|
||||
Связано с: QG-0 (gate входа конвейера, `_qg0_errors`)
|
||||
|
||||
## 1. Проблема (As-Is)
|
||||
QG-0 — первый quality gate конвейера. Он валидирует заголовок и описание задачи
|
||||
до старта pipeline (`start_pipeline`) и в soft-режиме на `work_item.created`.
|
||||
|
||||
Верхний лимит длины заголовка задачи **захардкожен** в
|
||||
`src/webhooks/plane.py:362`:
|
||||
|
||||
```python
|
||||
if len(name) > 80:
|
||||
errors.append("Title слишком длинный (максимум 80 символов)")
|
||||
```
|
||||
|
||||
Лимит 80 — «гигиенический», а не структурный. Проверено, что **ниже по течению
|
||||
ничего от значения 80 не зависит**:
|
||||
- slug ветки режется независимо: `re.sub(...)[:30]` (`src/webhooks/plane.py:478`);
|
||||
- БД `tasks.title TEXT` — без ограничения длины;
|
||||
- Telegram-карточка использует `html.escape(title)` без обрезки;
|
||||
- Plane хранит `name` самостоятельно.
|
||||
|
||||
Следствие: вполне валидные осмысленные заголовки длиной 81–200 символов
|
||||
отклоняются на входе конвейера без бизнес-причины.
|
||||
|
||||
## 2. Цель (To-Be)
|
||||
Вынести верхний лимит длины заголовка QG-0 в конфигурируемый параметр со
|
||||
значением по умолчанию **200** (вместо текущего хардкода 80). Расширить лимит
|
||||
безопасно, сохранив возможность регулировать его через окружение, как и
|
||||
остальные `ORCH_*` настройки.
|
||||
|
||||
## 3. Бизнес-ценность
|
||||
- Меньше ложных отклонений валидных задач на входе конвейера (QoL для постановщика).
|
||||
- Лимит становится операционно настраиваемым без правки кода и редеплоя
|
||||
(изменение env-переменной).
|
||||
- Изменение чисто аддитивное и обратносовместимое: дефолт 200 > прежних 80, поэтому
|
||||
все заголовки, проходившие раньше, проходят и теперь.
|
||||
|
||||
## 4. Объём (Scope)
|
||||
### В объёме
|
||||
- Новый параметр Settings `qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200).
|
||||
- Замена хардкода `> 80` на `> settings.qg0_title_max` в `_qg0_errors`.
|
||||
- Динамический текст ошибки с подстановкой актуального лимита.
|
||||
- Graceful-поведение при невалидном/пустом значении env → дефолт 200, без падения процесса.
|
||||
- Документация: `.env.example`, `.env.staging.example`, `CHANGELOG.md`,
|
||||
при необходимости README-таблица конфигов / `CLAUDE.md`.
|
||||
- Юнит-тесты на `_qg0_errors` с разными лимитами.
|
||||
|
||||
### Вне объёма (Out of scope)
|
||||
- Slug-логика `[:30]` (`src/webhooks/plane.py:478`) — самодостаточна, не трогать.
|
||||
- Нижний лимит заголовка (`< 5`) и лимит description (`< 20`) — оставить как есть.
|
||||
- Схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`.
|
||||
- Soft-QG-0 на `work_item.created` (там только warning) — логика валидации общая
|
||||
(`_qg0_errors`), отдельных изменений не требует и не вносит.
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
- Owner / постановщик задач (Слава) — снижение ложных отклонений.
|
||||
- Агенты конвейера — поведение QG-0 при старте pipeline.
|
||||
|
||||
## 6. Ограничения и риски (self-hosting)
|
||||
- Правка касается работающего в проде инструмента (self-hosting). Прод-контейнер
|
||||
`orchestrator` в рамках задачи **не рестартить**; обязательна страховка
|
||||
`deploy-staging` (8501).
|
||||
- Риск минимален: изменение обратносовместимо, изолировано в одной функции и одном
|
||||
новом параметре config.
|
||||
|
||||
## 7. Допущения
|
||||
- Механизм чтения env — стандартный `pydantic_settings.BaseSettings` с
|
||||
`env_prefix = "ORCH_"`, как у остальных параметров.
|
||||
- «Невалидное/пустое значение → дефолт 200» — требование graceful-деградации:
|
||||
процесс не должен падать на старте из-за мусора в `ORCH_QG0_TITLE_MAX`
|
||||
(нюанс реализации pydantic-валидации передаётся архитектору, см. 02-trz §5).
|
||||
95
docs/work-items/ORCH-069/02-trz.md
Normal file
95
docs/work-items/ORCH-069/02-trz.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# ТЗ — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200)
|
||||
|
||||
Work Item ID: ORCH-069
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
| Файл | Текущее состояние | Требуемое изменение |
|
||||
|------|-------------------|---------------------|
|
||||
| `src/config.py` | `Settings(BaseSettings)`, `env_prefix = "ORCH_"` (строки 4, 347-349) | Добавить поле `qg0_title_max: int = 200` с комментарием-описанием. |
|
||||
| `src/webhooks/plane.py` | `_qg0_errors` (строки 357-367), хардкод `if len(name) > 80:` (строка 362); `from ..config import settings` уже импортирован (строка 11) | Заменить хардкод `> 80` на `> settings.qg0_title_max`; текст ошибки — динамический с подстановкой лимита. |
|
||||
|
||||
Других модулей изменение не затрагивает.
|
||||
|
||||
## 2. Изменение config.py
|
||||
Добавить в класс `Settings` новое поле (рядом с другими `ORCH_*` группами,
|
||||
рекомендуется отдельный блок с комментарием):
|
||||
|
||||
```python
|
||||
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char
|
||||
# cap was a hygiene limit, not structural (slug is cut to [:30] independently,
|
||||
# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default
|
||||
# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash).
|
||||
qg0_title_max: int = 200
|
||||
```
|
||||
|
||||
- Env-переменная: `ORCH_QG0_TITLE_MAX` (автоматически из `env_prefix = "ORCH_"`).
|
||||
- Тип `int`, дефолт `200`.
|
||||
|
||||
## 3. Изменение `_qg0_errors` (src/webhooks/plane.py)
|
||||
Текущий блок (строки 362-363):
|
||||
```python
|
||||
if len(name) > 80:
|
||||
errors.append("Title слишком длинный (максимум 80 символов)")
|
||||
```
|
||||
|
||||
Требуемое:
|
||||
```python
|
||||
if len(name) > settings.qg0_title_max:
|
||||
errors.append(
|
||||
f"Title слишком длинный (максимум {settings.qg0_title_max} символов)"
|
||||
)
|
||||
```
|
||||
|
||||
Требования:
|
||||
- Лимит берётся из `settings.qg0_title_max` (динамически, на каждый вызов — чтобы
|
||||
тесты могли подменять значение через мок/патч settings).
|
||||
- Текст ошибки содержит актуальное число лимита (для AC-1/AC-2: текст упоминает
|
||||
200 / 120 соответственно).
|
||||
- Нижний лимит заголовка `< 5` (строка 360-361) и проверка description `< 20`
|
||||
(строка 364-365) — **не трогать**.
|
||||
- Сигнатура `_qg0_errors(name, description) -> list` не меняется.
|
||||
|
||||
## 4. Поведение границы (точная семантика)
|
||||
- Условие fail — строго `len(name) > limit`. То есть `len == limit` → PASS,
|
||||
`len == limit + 1` → FAIL.
|
||||
- При дефолте: 200 символов → PASS, 201 → FAIL.
|
||||
- При `ORCH_QG0_TITLE_MAX=120`: 120 → PASS, 121 → FAIL.
|
||||
|
||||
## 5. Graceful-обработка невалидного значения (требование AC-3)
|
||||
Требование: невалидное/отсутствующее `ORCH_QG0_TITLE_MAX` → используется дефолт 200,
|
||||
процесс не падает.
|
||||
|
||||
Нюанс для архитектора/разработчика: `pydantic_settings` по умолчанию при
|
||||
непарсящемся в `int` значении env (например `ORCH_QG0_TITLE_MAX=abc` или пустая
|
||||
строка) выбрасывает `ValidationError` на инстанцировании `Settings()` —
|
||||
т.е. падение на старте процесса. Это противоречит требованию graceful.
|
||||
Реализация должна обеспечить, что:
|
||||
- отсутствие переменной → дефолт 200 (это стандартное поведение, ОК «из коробки»);
|
||||
- пустая строка / нечисловое значение → дефолт 200 без исключения.
|
||||
|
||||
Способ (на усмотрение архитектора, без предписания со стороны аналитика) —
|
||||
например field-validator с `mode="before"`, который при невалидном входе
|
||||
возвращает дефолт. Конкретный механизм фиксируется в ADR на стадии architecture.
|
||||
|
||||
## 6. Изменения API
|
||||
Нет. Эндпоинты не меняются.
|
||||
|
||||
## 7. Изменения схемы БД
|
||||
Нет. `tasks.title TEXT` остаётся без ограничения длины.
|
||||
|
||||
## 8. Новые QG checks
|
||||
Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. QG-0 — не зарегистрированный
|
||||
stage-gate, а inline-валидация входа (`_qg0_errors`), её контракт сохраняется.
|
||||
|
||||
## 9. Артефакты pipeline, которые должны быть созданы/обновлены
|
||||
- `.env.example` — добавить `ORCH_QG0_TITLE_MAX=200` с комментарием.
|
||||
- `.env.staging.example` — добавить `ORCH_QG0_TITLE_MAX` (дефолт/комментарий).
|
||||
- `CHANGELOG.md` — запись об ORCH-069.
|
||||
- README-таблица конфигов / `CLAUDE.md` — обновить при наличии релевантной таблицы
|
||||
параметров (по требованию reviewer; документация = golden source).
|
||||
- Юнит-тесты (`tests/`) — см. `04-test-plan.yaml`.
|
||||
|
||||
## 10. Обратная совместимость
|
||||
- Дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят и теперь.
|
||||
- Поведение при не заданном env идентично «как было», но с порогом 200 вместо 80.
|
||||
- Изменение чисто аддитивное; откатов/миграций не требует.
|
||||
56
docs/work-items/ORCH-069/03-acceptance-criteria.md
Normal file
56
docs/work-items/ORCH-069/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Критерии приёмки — ORCH-069
|
||||
|
||||
Work Item ID: ORCH-069
|
||||
|
||||
Формат: каждый критерий имеет чёткое условие PASS/FAIL.
|
||||
|
||||
## AC-1 — Дефолтный лимит 200, граница на 201
|
||||
**Дано:** env `ORCH_QG0_TITLE_MAX` не задан (используется дефолт 200), description валиден (≥ 20 символов).
|
||||
**Тогда:**
|
||||
- заголовок длиной 200 символов → `_qg0_errors` НЕ содержит ошибки про длину title (PASS);
|
||||
- заголовок длиной 201 символ → `_qg0_errors` содержит ошибку про длину title, и текст ошибки упоминает «200».
|
||||
**FAIL если:** на 200 появляется ошибка длины, либо на 201 ошибки нет, либо текст не упоминает 200.
|
||||
|
||||
## AC-2 — Настраиваемый лимит 120, граница на 121
|
||||
**Дано:** `ORCH_QG0_TITLE_MAX=120` (через мок/патч settings в тесте), description валиден.
|
||||
**Тогда:**
|
||||
- заголовок 120 символов → нет ошибки длины title (PASS);
|
||||
- заголовок 121 символ → есть ошибка длины title, текст упоминает «120».
|
||||
**FAIL если:** граница срабатывает не на 121, либо текст ошибки упоминает не 120.
|
||||
|
||||
## AC-3 — Graceful при невалидном/пустом значении
|
||||
**Дано:** `ORCH_QG0_TITLE_MAX` пустой (`""`) или нечисловой (`"abc"`).
|
||||
**Тогда:**
|
||||
- инстанцирование `Settings()` / импорт приложения НЕ выбрасывает исключение (процесс не падает);
|
||||
- эффективное значение лимита = дефолт 200 (поведение AC-1 сохраняется).
|
||||
**FAIL если:** старт процесса падает с `ValidationError`, либо лимит != 200.
|
||||
|
||||
## AC-4 — Нижние лимиты не сломаны
|
||||
**Дано:** любое валидное значение `ORCH_QG0_TITLE_MAX`.
|
||||
**Тогда:**
|
||||
- заголовок длиной < 5 символов → `_qg0_errors` содержит ошибку «Title слишком короткий»;
|
||||
- description длиной < 20 символов → `_qg0_errors` содержит ошибку «Description слишком короткий».
|
||||
**FAIL если:** нижний лимит title или лимит description перестал срабатывать.
|
||||
|
||||
## AC-5 — Юнит-тесты зелёные
|
||||
**Дано:** реализованные юнит-тесты на `_qg0_errors` с разными значениями лимита (мок settings).
|
||||
**Тогда:** `pytest tests/ -q` проходит полностью (зелёный), включая новые тесты ORCH-069 и существующий набор.
|
||||
**FAIL если:** хотя бы один тест падает.
|
||||
|
||||
## AC-6 — Документация обновлена в том же PR
|
||||
**Дано:** PR с изменениями кода.
|
||||
**Тогда в том же PR:**
|
||||
- `.env.example` содержит `ORCH_QG0_TITLE_MAX` с дефолтом и комментарием;
|
||||
- `.env.staging.example` содержит `ORCH_QG0_TITLE_MAX`;
|
||||
- `CHANGELOG.md` содержит запись об ORCH-069;
|
||||
- при наличии релевантной таблицы конфигов в README / `CLAUDE.md` — она обновлена.
|
||||
**FAIL если:** какой-либо из обязательных файлов документации не обновлён (reviewer → REQUEST_CHANGES).
|
||||
|
||||
## AC-7 — Обратная совместимость
|
||||
**Дано:** env не задан.
|
||||
**Тогда:** любой заголовок, который проходил QG-0 при прежнем лимите 80 (len ≤ 80), проходит и теперь (len ≤ 200).
|
||||
**FAIL если:** ранее валидный заголовок отклоняется.
|
||||
|
||||
## AC-8 — Изоляция изменений
|
||||
**Тогда:** не изменены slug-логика (`[:30]`), схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`, soft-QG-0 поведение (warning на `work_item.created`).
|
||||
**FAIL если:** затронут любой из перечисленных вне-объёмных элементов.
|
||||
112
docs/work-items/ORCH-069/04-test-plan.yaml
Normal file
112
docs/work-items/ORCH-069/04-test-plan.yaml
Normal file
@@ -0,0 +1,112 @@
|
||||
work_item: ORCH-069
|
||||
description: >
|
||||
Юнит-тесты для конфигурируемого верхнего лимита длины заголовка QG-0
|
||||
(_qg0_errors) через параметр settings.qg0_title_max (env ORCH_QG0_TITLE_MAX,
|
||||
дефолт 200). Тесты патчат settings.qg0_title_max (monkeypatch на объекте
|
||||
src.config.settings, который импортирован в src.webhooks.plane) и проверяют
|
||||
границы и тексты ошибок. Файл тестов: tests/test_qg0_title_limit.py.
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "Дефолтный лимит 200: заголовок ровно 200 символов -> нет ошибки длины title (PASS на границе)."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "settings.qg0_title_max=200 (дефолт); name='x'*200; description валиден (>=20 символов)."
|
||||
assert: "В списке _qg0_errors нет элемента про длину title."
|
||||
covers: [AC-1]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Дефолтный лимит 200: заголовок 201 символ -> ошибка длины title, текст упоминает '200'."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "settings.qg0_title_max=200; name='x'*201; description валиден."
|
||||
assert: "В _qg0_errors есть ошибка длины title и её текст содержит подстроку '200'."
|
||||
covers: [AC-1]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Настраиваемый лимит 120: заголовок 120 символов -> нет ошибки длины title."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "monkeypatch settings.qg0_title_max=120; name='x'*120; description валиден."
|
||||
assert: "Нет ошибки длины title."
|
||||
covers: [AC-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "Настраиваемый лимит 120: заголовок 121 символ -> ошибка длины title, текст упоминает '120'."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "monkeypatch settings.qg0_title_max=120; name='x'*121; description валиден."
|
||||
assert: "Есть ошибка длины title и её текст содержит подстроку '120' (и НЕ '80')."
|
||||
covers: [AC-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Graceful: невалидное (нечисловое) значение env ORCH_QG0_TITLE_MAX не роняет инстанцирование Settings и даёт дефолт 200."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','abc'); создать новый экземпляр Settings()."
|
||||
assert: "Settings() не выбрасывает исключение; settings.qg0_title_max == 200."
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Graceful: пустая строка env ORCH_QG0_TITLE_MAX -> дефолт 200, без исключения."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX',''); создать новый экземпляр Settings()."
|
||||
assert: "Settings() не падает; settings.qg0_title_max == 200."
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Корректное числовое env -> применяется заданное значение (sanity положительного пути)."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','150'); создать новый экземпляр Settings()."
|
||||
assert: "settings.qg0_title_max == 150."
|
||||
covers: [AC-2, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Нижний лимит title не сломан: заголовок < 5 символов -> ошибка 'Title слишком короткий' при любом верхнем лимите."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "settings.qg0_title_max=200; name='abc' (3 символа); description валиден."
|
||||
assert: "В _qg0_errors есть ошибка короткого title."
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Лимит description не сломан: description < 20 символов -> ошибка 'Description слишком короткий'."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "settings.qg0_title_max=200; name валиден (>=5, <=200); description='short'."
|
||||
assert: "В _qg0_errors есть ошибка короткого description."
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "Обратная совместимость: заголовок длиной 81-200 (ранее отклонялся лимитом 80) теперь проходит при дефолте."
|
||||
module: tests/test_qg0_title_limit.py
|
||||
setup: "settings.qg0_title_max=200; name='x'*100; description валиден."
|
||||
assert: "Нет ошибки длины title (раньше при лимите 80 была бы)."
|
||||
covers: [AC-7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Полный набор тестов зелёный (регрессия не внесена)."
|
||||
module: tests/
|
||||
command: "pytest tests/ -q"
|
||||
assert: "Все тесты проходят."
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
|
||||
notes:
|
||||
- "settings импортирован в src.webhooks.plane как 'from ..config import settings', _qg0_errors читает settings.qg0_title_max динамически -> monkeypatch на src.config.settings.qg0_title_max (или импортируемом объекте) меняет поведение в рамках теста."
|
||||
- "Для TC-05/06/07 нужен СВЕЖИЙ экземпляр Settings(): глобальный src.config.settings создаётся один раз на импорт, поэтому env-тесты инстанцируют Settings() локально, а не полагаются на готовый синглтон."
|
||||
- "Тесты не требуют сети, БД, агентов или FastAPI TestClient — чистая проверка leaf-функции _qg0_errors и парсинга Settings."
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR-001: Конфигурируемый QG-0 title-лимит с graceful-деградацией env
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
QG-0 — inline-валидация входа конвейера (`_qg0_errors` в `src/webhooks/plane.py`),
|
||||
вызывается из `start_pipeline` (hard-блок) и из `handle_work_item_created`
|
||||
(soft-warning). Верхний лимит длины заголовка захардкожен: `if len(name) > 80`.
|
||||
|
||||
BRD/ТЗ (ORCH-069) установили, что лимит 80 — гигиенический, а не структурный:
|
||||
ниже по течению от него ничего не зависит (slug режется независимо `[:30]`,
|
||||
`tasks.title TEXT` без ограничения, Telegram/Plane хранят/экранируют сами).
|
||||
Валидные заголовки 81–200 символов отклоняются на входе без бизнес-причины.
|
||||
|
||||
Требуется:
|
||||
1. Вынести лимит в конфигурируемый параметр `ORCH_QG0_TITLE_MAX`, дефолт 200.
|
||||
2. **Graceful-деградация** (AC-3): пустое/нечисловое значение env → дефолт 200
|
||||
**без падения процесса**. Это и есть единственное нетривиальное архитектурное
|
||||
решение задачи: `pydantic_settings` v2 по умолчанию при непарсящемся в `int`
|
||||
значении env бросает `ValidationError` на инстанцировании `Settings()` —
|
||||
т.е. краш на старте контейнера (`settings = Settings()` на module-import,
|
||||
`src/config.py:352`). Для self-hosting это означало бы падение прод-инструмента
|
||||
из-за опечатки в env — недопустимо.
|
||||
|
||||
Стек подтверждён: `pydantic==2.13.4`, `pydantic-settings==2.5.0` (v2 API).
|
||||
|
||||
## Решение
|
||||
|
||||
### Р-1. Новый параметр Settings
|
||||
В `src/config.py`, в класс `Settings`, добавить поле (отдельный блок с
|
||||
комментарием, рядом с прочими `ORCH_*`):
|
||||
|
||||
```python
|
||||
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors).
|
||||
# 80-char cap was a hygiene limit, not structural. Env ORCH_QG0_TITLE_MAX;
|
||||
# default 200 (was hardcoded 80). Invalid/empty -> default (graceful, no crash).
|
||||
qg0_title_max: int = 200
|
||||
```
|
||||
Env-имя выводится автоматически из `env_prefix = "ORCH_"` → `ORCH_QG0_TITLE_MAX`.
|
||||
|
||||
### Р-2. Механизм graceful-деградации — `field_validator(mode="before")`
|
||||
Выбран **pydantic v2 `field_validator` с `mode="before"`** как
|
||||
минимально-инвазивный, локальный для одного поля механизм. Валидатор перехватывает
|
||||
сырое значение env ДО стандартного `int`-парсинга и при невалидном/пустом входе
|
||||
возвращает дефолт `200`, гася `ValidationError`:
|
||||
|
||||
```python
|
||||
from pydantic import field_validator
|
||||
|
||||
@field_validator("qg0_title_max", mode="before")
|
||||
@classmethod
|
||||
def _qg0_title_max_default(cls, v):
|
||||
# Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200,
|
||||
# process must not crash on startup. Never raises.
|
||||
try:
|
||||
if v is None or (isinstance(v, str) and v.strip() == ""):
|
||||
return 200
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 200
|
||||
```
|
||||
|
||||
Семантика:
|
||||
- переменная не задана → pydantic не вызывает validator с env, берётся дефолт поля
|
||||
`200` (стандартное поведение «из коробки»);
|
||||
- `""`, `"abc"`, мусор → validator возвращает `200`, исключения нет;
|
||||
- `"120"` → `int("120") == 120`.
|
||||
|
||||
**Почему именно так (рассмотренные альтернативы):**
|
||||
- *`Optional[int] + None-fallback на месте чтения`* — отвергнуто: размазывает
|
||||
дефолт по call-site'ам, легко забыть, тип поля перестаёт быть «честным `int`».
|
||||
- *try/except вокруг `Settings()` на module-level* — отвергнуто: глушит ВСЕ
|
||||
ошибки конфигурации (маскирует реальные проблемы других полей), слишком грубо.
|
||||
- *кастомный тип / `Annotated`-валидатор* — избыточно для одного поля.
|
||||
- `field_validator(mode="before")` локален, не трогает остальные поля, не меняет
|
||||
публичный тип `int`, тестируется напрямую через `Settings(qg0_title_max=...)` и
|
||||
env-патч. Контракт «never-raise» консистентен с общим стилем кодовой базы
|
||||
(`_qg0_errors`, парсеры — defensive).
|
||||
|
||||
### Р-3. Использование лимита в `_qg0_errors`
|
||||
Хардкод `> 80` → динамическое чтение `settings.qg0_title_max` **на каждый вызов**
|
||||
(чтобы тест мог патчить `settings`), текст ошибки — f-string с актуальным числом:
|
||||
|
||||
```python
|
||||
if len(name) > settings.qg0_title_max:
|
||||
errors.append(
|
||||
f"Title слишком длинный (максимум {settings.qg0_title_max} символов)"
|
||||
)
|
||||
```
|
||||
`settings` уже импортирован в `plane.py`. Сигнатура `_qg0_errors(name, description)
|
||||
-> list` не меняется. Нижние лимиты (`< 5` title, `< 20` description) — без правок.
|
||||
|
||||
Граница (ТЗ §4): fail строго при `len(name) > limit` → `len == limit` PASS,
|
||||
`limit + 1` FAIL.
|
||||
|
||||
### Р-4. Что НЕ меняется (инварианты)
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS` — QG-0 не зарегистрированный stage-gate, а
|
||||
inline-валидация; реестры не трогаются.
|
||||
- Схема БД (`tasks.title TEXT`), API, контракты `handle_*`, slug-логика `[:30]`,
|
||||
soft-QG-0 поведение (общая функция `_qg0_errors`, отдельной правки не требует).
|
||||
- Топология/инфраструктура (`07-infra-requirements.md` — **N/A**) и схема данных
|
||||
(`08-data-requirements.md` — **N/A**) не затрагиваются.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- Лимит операционно настраивается через env без правки кода и редеплоя кода.
|
||||
- Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее
|
||||
проходившие заголовки проходят (AC-7).
|
||||
- Опечатка в `ORCH_QG0_TITLE_MAX` не роняет прод-процесс (критично для
|
||||
self-hosting): graceful-fallback на 200.
|
||||
- Изменение изолировано в одной функции + одном поле config + одном валидаторе.
|
||||
|
||||
### Минусы / ограничения
|
||||
- Невалидное env «тихо» проглатывается → оператор не сразу заметит опечатку
|
||||
(лимит молча станет 200). Принято как осознанный trade-off: устойчивость
|
||||
процесса важнее громкости (consistency с требованием AC-3). Рекомендация:
|
||||
при желании усилить наблюдаемость — `logger.warning` в validator; **не вводим**
|
||||
по умолчанию, т.к. на этапе валидации settings логгер может быть не сконфигурён,
|
||||
и это вне объёма ORCH-069 (можно отдельной QoL-задачей).
|
||||
- Дефолт 200 — тоже эвристика; структурного верхнего предела по-прежнему нет
|
||||
(его и не требуется — БД/slug/UI к длине устойчивы).
|
||||
|
||||
### Влияние на self-hosting
|
||||
Прод-контейнер `orchestrator` **не рестартить** в рамках задачи. Изменение
|
||||
прокатывается штатно через обязательный `deploy-staging`-гейт (8501) перед
|
||||
прод-деплоем. Риск отказа на старте после деплоя снят самим механизмом Р-2
|
||||
(graceful), что дополнительно снижает self-hosting-риск.
|
||||
|
||||
### Тестируемость (вход для стадий development/testing)
|
||||
- `_qg0_errors`: патч `settings.qg0_title_max` → проверка границ 200/201 (AC-1),
|
||||
120/121 (AC-2), нижних лимитов (AC-4).
|
||||
- validator: `Settings(qg0_title_max="abc")` / `=""` / env-патч → значение 200,
|
||||
без исключения (AC-3).
|
||||
|
||||
## Ссылки
|
||||
- BRD: `docs/work-items/ORCH-069/01-brd.md`
|
||||
- ТЗ: `docs/work-items/ORCH-069/02-trz.md`
|
||||
- Acceptance: `docs/work-items/ORCH-069/03-acceptance-criteria.md`
|
||||
- Тех-риски: `docs/work-items/ORCH-069/10-tech-risks.md`
|
||||
</content>
|
||||
</invoke>
|
||||
21
docs/work-items/ORCH-069/10-tech-risks.md
Normal file
21
docs/work-items/ORCH-069/10-tech-risks.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Технические риски — ORCH-069
|
||||
|
||||
Work Item ID: ORCH-069
|
||||
Уровень общего риска: **низкий** (аддитивное, обратносовместимое, изолированное изменение).
|
||||
|
||||
| # | Риск | Вероятность | Влияние | Митигация |
|
||||
|---|------|-------------|---------|-----------|
|
||||
| R-1 | `ValidationError` на старте при мусоре в `ORCH_QG0_TITLE_MAX` → краш прод-процесса (self-hosting) | Средняя (опечатка в env) | Высокое (падение инструмента всех проектов) | `field_validator(mode="before")` гасит невалидный вход → дефолт 200 (ADR Р-2, AC-3). never-raise. |
|
||||
| R-2 | Чтение лимита один раз на module-import вместо per-call → тесты не смогут патчить settings | Низкая | Среднее (нетестируемость AC-2) | `_qg0_errors` читает `settings.qg0_title_max` динамически на каждый вызов (ADR Р-3). |
|
||||
| R-3 | Off-by-one на границе (`>=` вместо `>`) | Низкая | Низкое (1 символ) | Явная семантика `len > limit` зафиксирована (ТЗ §4, AC-1/AC-2); тесты на 200/201, 120/121. |
|
||||
| R-4 | Регресс нижних лимитов (`< 5` title, `< 20` description) при правке функции | Низкая | Среднее | Трогать только верхний лимит; AC-4 покрывает нижние; диф минимален. |
|
||||
| R-5 | Тихое проглатывание невалидного env → оператор не заметит опечатку | Средняя | Низкое (лимит молча = 200, конвейер работает) | Осознанный trade-off (ADR «Минусы»): устойчивость > громкость. Опц. `logger.warning` — вне объёма. |
|
||||
| R-6 | Случайное затрагивание вне-объёмных элементов (slug `[:30]`, БД, реестры, `handle_*`, soft-QG-0) | Низкая | Среднее | AC-8 — изоляция; reviewer проверяет диф; ADR Р-4 фиксирует инварианты. |
|
||||
| R-7 | Документация не обновлена в том же PR (`.env.example`, `.env.staging.example`, `CHANGELOG.md`) | Средняя | Среднее (reviewer REQUEST_CHANGES) | AC-6 чек-лист; документация = golden source (правило 2 CLAUDE.md). |
|
||||
|
||||
## Не-риски (явно)
|
||||
- Схема БД — не меняется (`tasks.title TEXT` без ограничения).
|
||||
- API/эндпоинты — не меняются.
|
||||
- Топология/контейнеры/порты — не меняются.
|
||||
- Откат/миграция — не требуется (дефолт 200 > 80, чисто аддитивно).
|
||||
</content>
|
||||
68
docs/work-items/ORCH-069/12-review.md
Normal file
68
docs/work-items/ORCH-069/12-review.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-069
|
||||
verdict: APPROVED
|
||||
version: 3
|
||||
---
|
||||
|
||||
# Review ORCH-069
|
||||
|
||||
## Summary
|
||||
Реализация конфигурируемого QG-0 title-лимита `ORCH_QG0_TITLE_MAX` (дефолт 200)
|
||||
выполнена **дословно по ТЗ/ADR** и качественно. Поле `Settings.qg0_title_max`,
|
||||
graceful `field_validator(mode="before")` (never-raise → дефолт 200), динамическое
|
||||
чтение `settings.qg0_title_max` в `_qg0_errors` с f-string-текстом ошибки. Код
|
||||
изолирован (затронуты только `src/config.py` и `src/webhooks/plane.py`), инварианты
|
||||
не нарушены, нижние лимиты сохранены. Свежий полный прогон на текущем состоянии
|
||||
ветки: `pytest tests/ -q` → **863 passed** (включая 10 новых тестов ORCH-069,
|
||||
файл `tests/test_qg0_title_limit.py`, все зелёные). Документация обновлена в том же
|
||||
PR полностью. Блокирующих и must-fix findings нет → **APPROVED**.
|
||||
|
||||
## Соответствие ТЗ / ADR
|
||||
- `src/config.py` — поле `qg0_title_max: int = 200` + валидатор `_qg0_title_max_default`
|
||||
(`mode="before"`, try/except → 200 при `None`/пустой/нечисловой): 1:1 с ADR Р-1/Р-2
|
||||
и ТЗ §2/§5. ✓
|
||||
- `src/webhooks/plane.py` — хардкод `> 80` заменён на `> settings.qg0_title_max`,
|
||||
текст ошибки динамический (f-string с актуальным числом); сигнатура `_qg0_errors`,
|
||||
нижний лимит title `< 5`, проверка description `< 20` не тронуты: ADR Р-3, ТЗ §3/§4. ✓
|
||||
- Граница строгая (`len == limit` PASS, `limit+1` FAIL) — подтверждена tc01–tc04. ✓
|
||||
- Инварианты (ADR Р-4 / AC-8): `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, slug `[:30]`,
|
||||
soft-QG-0, API — НЕ изменены (diff `src/` = только 2 файла). ✓
|
||||
|
||||
## Acceptance criteria
|
||||
- AC-1 (дефолт 200, граница 201, текст упоминает 200) — tc01/tc02 ✓
|
||||
- AC-2 (лимит 120, граница 121, текст 120 не 80) — tc03/tc04 ✓
|
||||
- AC-3 (graceful пустое/`abc` → 200 без краха) — tc05/tc06 + позитив tc07 + валидатор ✓
|
||||
- AC-4 (нижние лимиты title<5 / desc<20) — tc08/tc09 ✓
|
||||
- AC-5 (pytest зелёный) — 863 passed ✓
|
||||
- AC-6 (документация в том же PR) — выполнен полностью ✓
|
||||
- AC-7 (обратная совместимость, ≤80 проходит при 200) — tc10 ✓
|
||||
- AC-8 (изоляция изменений) — ✓
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
### P3 — Nice-to-have (не блокирует)
|
||||
- В конце `06-adr/ADR-001-configurable-qg0-title-limit.md` присутствуют артефактные
|
||||
хвостовые теги (`</content>`, `</invoke>`). Косметика в артефакте стадии architecture;
|
||||
на корректность кода/контракта не влияет. Править артефакт чужой стадии в рамках
|
||||
ревью не уполномочен — отмечено для будущей чистки.
|
||||
|
||||
## Документация
|
||||
- `.env.example` — добавлен `ORCH_QG0_TITLE_MAX=200` с комментарием. ✓
|
||||
- `.env.staging.example` — добавлен `ORCH_QG0_TITLE_MAX=200`. ✓
|
||||
- `CHANGELOG.md` — подробная запись об ORCH-069 (раздел Added). ✓
|
||||
- `README.md` — таблица env-конфигов дополнена строкой `ORCH_QG0_TITLE_MAX`. ✓
|
||||
- ADR `06-adr/ADR-001-configurable-qg0-title-limit.md` — присутствует, согласован
|
||||
с кодом. ✓
|
||||
- `docs/architecture/README.md` / `CLAUDE.md` — обновления не требуют (QG-0 — inline
|
||||
soft/hard-валидация входа, не зарегистрированный stage-gate; API/стадии/QG-реестр
|
||||
не менялись). ОК.
|
||||
98
docs/work-items/ORCH-069/13-test-report.md
Normal file
98
docs/work-items/ORCH-069/13-test-report.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-069
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-069
|
||||
|
||||
QG-0 title-лимит → параметр `ORCH_QG0_TITLE_MAX` (дефолт 200)
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; asyncio mode=auto)
|
||||
- Ветка: `feature/ORCH-069-qg-0-title-orch-qg0-title-max-`
|
||||
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-069-qg-0-title-orch-qg0-title-max-`
|
||||
- Prod-health (8500): `{"status":"ok","service":"orchestrator"}` — не трогался (self-hosting safety)
|
||||
- Дата: 2026-06-08
|
||||
|
||||
## Предусловия
|
||||
- Review-вердикт `12-review.md`: **APPROVED** (version 3) ✓
|
||||
- Изменения изолированы: `src/config.py`, `src/webhooks/plane.py` (+ тесты, + документация)
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Покрывает | Результат |
|
||||
|-------|----------|-----------|-----------|
|
||||
| TC-01 | Дефолт 200: title=200 → нет ошибки длины (граница PASS) | AC-1 | PASS |
|
||||
| TC-02 | Дефолт 200: title=201 → ошибка длины, текст упоминает «200» | AC-1 | PASS |
|
||||
| TC-03 | Лимит 120: title=120 → нет ошибки длины | AC-2 | PASS |
|
||||
| TC-04 | Лимит 120: title=121 → ошибка, текст «120» (не «80») | AC-2 | PASS |
|
||||
| TC-05 | Graceful: env `abc` → дефолт 200, без краха `Settings()` | AC-3 | PASS |
|
||||
| TC-06 | Graceful: пустой env `""` → дефолт 200, без исключения | AC-3 | PASS |
|
||||
| TC-07 | Валидный env `150` → применяется 150 (позитивный путь) | AC-2, AC-3 | PASS |
|
||||
| TC-08 | Нижний лимит title < 5 не сломан | AC-4 | PASS |
|
||||
| TC-09 | Лимит description < 20 не сломан | AC-4 | PASS |
|
||||
| TC-10 | Обратная совместимость: title 81–200 проходит при дефолте | AC-7 | PASS |
|
||||
| TC-11 | Полный набор тестов зелёный (нет регрессии) | AC-5 | PASS |
|
||||
|
||||
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
|
||||
|
||||
| AC | Критерий | Статус |
|
||||
|----|----------|--------|
|
||||
| AC-1 | Дефолт 200, граница на 201, текст упоминает 200 | PASS (TC-01/02) |
|
||||
| AC-2 | Настраиваемый лимит 120, граница 121, текст 120 | PASS (TC-03/04/07) |
|
||||
| AC-3 | Graceful при пустом/нечисловом значении → 200 | PASS (TC-05/06) |
|
||||
| AC-4 | Нижние лимиты title<5 / description<20 не сломаны | PASS (TC-08/09) |
|
||||
| AC-5 | Юнит-тесты зелёные (весь набор) | PASS (863 passed) |
|
||||
| AC-6 | Документация в том же PR (.env.example, .env.staging.example, CHANGELOG, README) | PASS (подтверждено review) |
|
||||
| AC-7 | Обратная совместимость (≤80 проходит при 200) | PASS (TC-10) |
|
||||
| AC-8 | Изоляция: slug `[:30]`, БД, STAGE_TRANSITIONS/QG_CHECKS, handle_* не тронуты | PASS (diff = 2 файла src/) |
|
||||
|
||||
## Smoke test API (prod 8500, read-only)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → отдаёт активные задачи (ORCH-069 в стадии `testing`) — OK
|
||||
- `GET /queue` → `counts: queued=0 running=1 done=459 failed=4 cancelled=1`; breaker `closed`, preflight ok — OK
|
||||
|
||||
## Целевой прогон ORCH-069 (tests/test_qg0_title_limit.py)
|
||||
```
|
||||
collected 10 items
|
||||
|
||||
tests/test_qg0_title_limit.py::test_tc01_default_limit_200_boundary_pass PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc02_default_limit_200_boundary_fail PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc03_custom_limit_120_boundary_pass PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc04_custom_limit_120_boundary_fail PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc05_graceful_non_numeric_env PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc06_graceful_empty_env PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc07_valid_numeric_env PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc08_short_title_still_errors PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc09_short_description_still_errors PASSED
|
||||
tests/test_qg0_title_limit.py::test_tc10_backward_compat_titles_81_to_200 PASSED
|
||||
|
||||
======================== 10 passed, 1 warning in 0.31s =========================
|
||||
```
|
||||
|
||||
## Полный прогон (pytest tests/ -q)
|
||||
```
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 91%]
|
||||
....................................................................... [100%]
|
||||
863 passed, 1 warning in 21.49s
|
||||
```
|
||||
|
||||
(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, существующий
|
||||
class-based config; к ORCH-069 не относится, не является ошибкой.)
|
||||
|
||||
## Итог
|
||||
**PASS** — все 11 TC из тест-плана пройдены, все 8 критериев приёмки выполнены,
|
||||
полный регресс зелёный (863 passed), smoke-тесты API OK. Регрессии не внесены.
|
||||
Задача готова к переходу на стадию `deploy-staging`.
|
||||
12
docs/work-items/ORCH-069/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-069/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-069
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
25
docs/work-items/ORCH-069/15-staging-log.md
Normal file
25
docs/work-items/ORCH-069/15-staging-log.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T11:20:02+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub` (run through the Docker Engine API since the `docker` CLI is not installed on the host; equivalent in-container execution per ORCH-048/ADR-001).
|
||||
|
||||
**Result: 8/10 checks PASS — exit code 0 → SUCCESS.**
|
||||
|
||||
All REAL (pipeline) checks are green. The two failing checks are the known SANDBOX_INFRA checks (C9a/C9b), waived per ORCH-061 because every REAL check passed.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Block results
|
||||
- **[Block A] SMOKE** — A1 /health 200 ok ✓ · A2 /queue 200 with counts ✓ · A3 ORCH_STAGING=true ✓
|
||||
- **[Block B] ACCESS** — B4 Plane sandbox accessible ✓ · B5 Gitea orchestrator-sandbox push=true ✓ · B6 Registry isolation (sandbox present, prod ET/ORCH absent) ✓
|
||||
- **[Block C] E2E (stub)** — C7 Create issue in Plane SANDBOX ✓ · C8 Trigger pipeline via /webhook/plane ✓ · C9a Branch appears in orchestrator-sandbox ✗ (SANDBOX_INFRA, waived) · C9b Analyst job enqueued ✗ (SANDBOX_INFRA, waived)
|
||||
|
||||
REAL failed: none. SANDBOX_INFRA failed: C9a, C9b (waived). tolerance: staging_infra_tolerance_enabled=True.
|
||||
25
docs/work-items/ORCH-069/17-security-report.md
Normal file
25
docs/work-items/ORCH-069/17-security-report.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
security_status: PASS
|
||||
secrets_found: 0
|
||||
deps_blocking: 0
|
||||
deps_warning: 4
|
||||
deps_audit_degraded: false
|
||||
---
|
||||
# Security Report — ORCH-069
|
||||
|
||||
Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.
|
||||
|
||||
## Verdict
|
||||
clean: 0 secrets, 0 blocking CVE(s)
|
||||
|
||||
## Secrets
|
||||
- None
|
||||
|
||||
## Dependencies (blocking)
|
||||
- None
|
||||
|
||||
## Dependencies (warning)
|
||||
- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3
|
||||
- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1
|
||||
- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0
|
||||
- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2
|
||||
7
docs/work-items/ORCH-073/00-business-request.md
Normal file
7
docs/work-items/ORCH-073/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: CRIT: эрозия main — код ORCH-067/069 затёрт ребейзами, не доехал
|
||||
|
||||
Work Item ID: ORCH-073
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
98
docs/work-items/ORCH-073/01-brd.md
Normal file
98
docs/work-items/ORCH-073/01-brd.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 01 — BRD: ORCH-073 — CRIT: эрозия main (код ORCH-067/069 затёрт ребейзами, не доехал)
|
||||
|
||||
- **Work Item:** ORCH-073
|
||||
- **Тип:** BUG CRITICAL — целостность `main`, накопительный регресс/эрозия
|
||||
- **Репозиторий:** orchestrator (self-hosting)
|
||||
- **Ветка:** `feature/ORCH-073-crit-main-orch-067-069`
|
||||
- **Связь:** усиливает/чинит ORCH-071 (merge-verify); НЕ покрыт ORCH-071.
|
||||
|
||||
## 1. Бизнес-проблема
|
||||
|
||||
Код успешно «задеплоенных» и переведённых в `done` задач **ORCH-067** (tracker bump,
|
||||
Plane-статусы, кликабельные ссылки `plane_issue_link`) и **ORCH-069** (`qg0_title_max`)
|
||||
**физически отсутствовал в `origin/main`**, хотя обе прошли весь конвейер, Confirm Deploy,
|
||||
merge-verify `CONFIRMED` и стали `done`. В `main` попадали только их **docs-коммиты**
|
||||
(staging-log / verdict через отдельные авто docs-PR), но НЕ код feature-веток.
|
||||
|
||||
Внешнее проявление (нашёл Слава, 08.06): «ссылок на задачу в Plane нет», карточка Telegram
|
||||
показывает сырой номер задачи вместо кликабельной ссылки — потому что код ссылок есть в ветке
|
||||
ORCH-067, но не в `main`.
|
||||
|
||||
**Накопительный характер:** каждая новая задача срезает ветку от УСТАРЕВШЕГО `main` и при merge
|
||||
тихо (без конфликт-маркеров) затирает код предшественника. Уже потеряны ORCH-067 и ORCH-069;
|
||||
без системного фикса теряется код каждой следующей задачи с правкой `CHANGELOG.md`.
|
||||
|
||||
## 2. Подтверждённый root cause (git-аудит 08.06, не гипотеза)
|
||||
|
||||
1. **`verify_merged_to_main` подтверждает merge по ложному признаку.**
|
||||
`src/merge_gate.py::verify_merged_to_main` возвращает `True`, если выполнено **ЛИБО**
|
||||
`pr_already_merged(repo, branch)`, **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
|
||||
Первая ветка (`pr_already_merged`) и есть дыра.
|
||||
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR ветки.**
|
||||
`src/merge_gate.py::pr_already_merged` делает `GET /pulls?state=all&head=<branch>` и
|
||||
возвращает `True`, если **хоть один** PR `merged==True`. У одной ветки несколько PR
|
||||
(code-PR + авто docs-PR со staging/deploy-логами). Сливается docs-PR → функция говорит
|
||||
«already-merged» → `verify_merged_to_main`=`True` → merge-verify `CONFIRMED` → `done`,
|
||||
хотя code-PR НЕ слит. **Ложно-зелёный.**
|
||||
3. **CHANGELOG.md-ребейзы — вторичный усилитель.**
|
||||
Merge-gate `auto_rebase_onto_main` при конфликте `CHANGELOG.md` откатывает `deploy-staging →
|
||||
development`; повторный ребейз ветки от старого `main` несёт устаревшие версии файлов
|
||||
(`notifications.py`/`config.py`/`webhooks/plane.py`), которые при merge тихо затирают
|
||||
соседний код (фантом-эффект, как в ORCH-071, без конфликт-маркеров).
|
||||
|
||||
> Уточнение для архитектора: в ТЗ упомянута «инвертированная проверка `merge-base --is-ancestor
|
||||
> origin/main HEAD` (merge_gate.py ~76)» — это `branch_is_behind_main` (детектор «ветка
|
||||
> свежая»), он корректен для своей цели. Фактический дефект merge-verify — это OR-ветка
|
||||
> `pr_already_merged` в `verify_merged_to_main` (строка ~649), которая засчитывает docs-PR.
|
||||
|
||||
## 3. Состояние на момент анализа (G1)
|
||||
|
||||
Аудит `origin/main` показал, что **восстановительный PR #76** (`restore(main): re-merge
|
||||
ORCH-067 + ORCH-069 (ORCH-073)`) уже вернул код в `main`:
|
||||
- `plane_issue_link` присутствует (`src/notifications.py`), `qg0_title_max` присутствует
|
||||
(`src/config.py`, `src/webhooks/plane.py`), `verify_merged_to_main` присутствует.
|
||||
|
||||
Таким образом **G1 (восстановление кода) фактически выполнено** ручным restore-PR. Задача
|
||||
ORCH-073 должна **подтвердить и зафиксировать** это в критериях приёмки (AC-1) и сосредоточиться
|
||||
на **системном фиксе навсегда** (G2–G5 / FR-1…FR-5), иначе регресс повторится.
|
||||
|
||||
## 4. Цели (Goals)
|
||||
|
||||
- **G1.** КОД ORCH-067 и ORCH-069 присутствует в `origin/main` одновременно с ORCH-071
|
||||
(подтвердить restore-PR #76, зафиксировать маркеры > 0). Pytest зелёный. Прод задеплоен.
|
||||
- **G2 (FR-2/FR-3).** `merge`/`pr_already_merged` различают **code-PR** и **docs-PR** — merge
|
||||
засчитывается только за PR с кодом ветки (`base==main`, `head==<feature-branch>`).
|
||||
- **G3 (FR-1, ядро).** `verify_merged_to_main` подтверждает merge **ТОЛЬКО** по факту «deployed
|
||||
SHA — предок `origin/main`». PR-флаги вспомогательны, не достаточны.
|
||||
- **G4 (FR-4).** Защита от CHANGELOG-затирания: `.gitattributes` с `CHANGELOG.md merge=union`
|
||||
(+ опц. `docs/*.md merge=union` для append-only).
|
||||
- **G5 (FR-5, регресс-гард навсегда).** После деплоя — sanity-проверка целостности `main`:
|
||||
deployed SHA в `main` И набор маркеров ранее-merged задач не уменьшился. Откат соседнего кода
|
||||
→ alert «main regressed», задача НЕ `done`.
|
||||
|
||||
## 5. Не-цели (Out of scope)
|
||||
|
||||
- Не менять Plane / схему БД.
|
||||
- Не отменять self-hosting safety (не ронять прод, merge только через PR-API, без force-push в `main`).
|
||||
- Не менять ручной гейт `Confirm Deploy`.
|
||||
- Не менять поведение merge/verify для non-self репозиториев (enduro-trails) — обратная совместимость.
|
||||
|
||||
## 6. Инварианты
|
||||
|
||||
- **INV-1.** never-raise на верификации (alert, не падение).
|
||||
- **INV-2.** self-hosting safety: прод не падает; merge только PR-API, без force-push в `main`.
|
||||
- **INV-3.** ручной `Confirm Deploy` сохранён.
|
||||
- **INV-4.** Идемпотентность: повторный прогон / reaper не делает второй merge; idempotency
|
||||
опирается на «SHA-в-main», а не на «любой merged PR».
|
||||
- **INV-5.** Обратная совместимость non-self (enduro): поведение merge/verify без изменений.
|
||||
|
||||
## 7. Заинтересованные стороны
|
||||
|
||||
- **Owner / Слава** — потребитель (видит кликабельные ссылки в карточке; доверие к merge-verify).
|
||||
- **Все проекты на инстансе** (enduro-trails) — общий `main`/очередь/БД; регресс орка = групповой риск.
|
||||
|
||||
## 8. Срочность
|
||||
|
||||
КРИТИКАЛ. Без FR-1/FR-4/FR-5 каждая новая задача с правкой `CHANGELOG.md` продолжает терять код
|
||||
предшественников (уже потеряны 067, 069). Ложно-зелёный merge-verify подрывает само ядро
|
||||
автономности конвейера.
|
||||
129
docs/work-items/ORCH-073/02-trz.md
Normal file
129
docs/work-items/ORCH-073/02-trz.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 02 — ТЗ: ORCH-073 — системный фикс эрозии main + восстановление кода 067/069
|
||||
|
||||
> ТЗ описывает ТРЕБУЕМОЕ ПОВЕДЕНИЕ и точки изменения. Выбор конкретного дизайна
|
||||
> (где именно резать docs-PR от code-PR, формат набора регресс-маркеров) — за архитектором (`06-adr`).
|
||||
> Запрещено комментировать ТЗ задним числом: если требование не годится — вернуть в Анализ.
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в фиксе | FR |
|
||||
| --- | --- | --- |
|
||||
| `src/merge_gate.py` | `verify_merged_to_main`, `pr_already_merged`, `merge_pr`, новый регресс-гард | FR-1, FR-2, FR-3, FR-5 |
|
||||
| `src/stage_engine.py` | `_handle_merge_verify` (под-гейт `deploy → done`) — точка вызова FR-1/FR-5 | FR-1, FR-5 |
|
||||
| `src/config.py` | (опц.) настройки регресс-гарда: kill-switch + набор маркеров/таймаут | FR-5 |
|
||||
| `.gitattributes` (корень репо, новый) | `CHANGELOG.md merge=union` (+ опц. `docs/*.md merge=union`) | FR-4 |
|
||||
| `docs/architecture/README.md` | раздел merge-verify — обновить под новую семантику | AC-8 |
|
||||
| `CHANGELOG.md` | запись Unreleased | AC-8 |
|
||||
| `docs/work-items/ORCH-073/06-adr/` | ADR на новую семантику merge-verify + регресс-гард | AC-8 |
|
||||
|
||||
## 2. Требуемые изменения по коду
|
||||
|
||||
### FR-1 (G3, ядро) — `verify_merged_to_main` чинит семантику
|
||||
**Текущее (баг):** `src/merge_gate.py::verify_merged_to_main(repo, branch, sha)` возвращает `True`,
|
||||
если `pr_already_merged(...)` **ИЛИ** `git merge-base --is-ancestor <sha> origin/main`.
|
||||
OR-ветка `pr_already_merged` засчитывает docs-PR → ложно-зелёный.
|
||||
|
||||
**Требование:** подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком
|
||||
`origin/main`»:
|
||||
- после `git fetch origin main` выполнить `git merge-base --is-ancestor <deployed_sha> origin/main`;
|
||||
- `rc==0` → `True` (код в main), иначе → `False`.
|
||||
- `pr_already_merged` **НЕ может быть единственным/достаточным** условием `True`. Допустимо
|
||||
оставить PR-флаг только как **вспомогательный** сигнал (idempotency / диагностика), но он НЕ
|
||||
должен подтверждать merge при отсутствии SHA в main.
|
||||
- Пустой `sha` → неопределённо → `False` (fail-closed: alert + HOLD), как сейчас.
|
||||
- never-raise: любая git/HTTP-ошибка → `False` (INV-1).
|
||||
|
||||
### FR-2 (G2) — `pr_already_merged` различает code-PR и docs-PR
|
||||
**Текущее (баг):** `src/merge_gate.py::pr_already_merged` возвращает `True` за ЛЮБОЙ
|
||||
`merged==True` PR из `GET /pulls?state=all&head=<branch>` — включая авто docs-PR.
|
||||
|
||||
**Требование (на выбор архитектора, предпочтителен вариант «б»):**
|
||||
- **(а)** засчитывать merged только для PR, реально несущего код ветки: `base.ref==main`
|
||||
И `head.ref==<feature-branch>` (исключить docs/* ветки и docs-only PR); **или**
|
||||
- **(б, предпочтительно)** понизить роль `pr_already_merged` до **idempotency-guard**: единственный
|
||||
критерий «merged/done» — SHA-предок-`main` (FR-1); PR-флаги вспомогательны.
|
||||
- Поведение для non-self репо (enduro) не меняется (INV-5).
|
||||
- never-raise → `False` (консервативно).
|
||||
|
||||
### FR-3 (G2) — `merge_pr` реально сливает code-ветку
|
||||
**Требование:** `src/merge_gate.py::merge_pr` мержит ИМЕННО feature-PR с кодом (`base==main`,
|
||||
`head==<feature-branch>`), а не полагается на docs-PR. После merge — обязательная верификация
|
||||
по FR-1 (SHA в main) как единственный источник истины. Merge только через Gitea PR-merge API,
|
||||
никогда push/force-push в `main` (INV-2).
|
||||
|
||||
### FR-5 (G3 регресс-гард, защита навсегда) — sanity-проверка целостности main
|
||||
**Требование:** перед фиксацией `done` (в `_handle_merge_verify`, ПОСЛЕ зелёного
|
||||
`check_deploy_status`, до `update_task_stage`):
|
||||
1. Подтвердить FR-1 (deployed SHA — предок `origin/main`).
|
||||
2. (опц., по дизайну) Проверить, что в `origin/main` присутствует **набор маркеров** ключевых
|
||||
функций недавно-merged задач (regression marker set) — merge не уменьшил его.
|
||||
3. При откате соседнего кода / отсутствии маркера → **alert** «main regressed: code of <prev
|
||||
tasks> missing» (Telegram + Plane), задача **НЕ `done`** (HOLD), как ветка not-merged в ORCH-071.
|
||||
- Реакция — **ALERT-only + HOLD**, без авто-отката на `development` (это инфра-дефект, не код-фолт).
|
||||
- never-raise (INV-1); kill-switch (как `merge_verify_enabled`); условность только для self-hosting
|
||||
/ `merge_verify_repos` (INV-5).
|
||||
- Набор маркеров — конфигурируемый/декларативный (например, в `src/config.py` или рядом), чтобы
|
||||
следующие задачи могли его расширять. Точный формат — за архитектором.
|
||||
|
||||
### FR-4 (G2/G4 корень) — `.gitattributes` с `merge=union`
|
||||
**Требование:** в корне репо завести `.gitattributes`:
|
||||
```
|
||||
CHANGELOG.md merge=union
|
||||
# опционально для append-only документов:
|
||||
# docs/**/*.md merge=union # ВНИМАНИЕ: union НЕ годится для файлов, где правки
|
||||
# переписывают строки — применять только к append-only
|
||||
```
|
||||
- `merge=union` встроен в git (драйвер по умолчанию), доп. конфиг хоста не требуется — но
|
||||
проверить, что атрибут реально применяется в worktree агентов (`git check-attr merge CHANGELOG.md`).
|
||||
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
|
||||
сохраняются) без конфликта → ветка не откатывается в `development` и не затирает соседний код.
|
||||
|
||||
## 3. Изменения API
|
||||
|
||||
- **Внешних HTTP API оркестратора (`src/main.py` endpoints) НЕ менять.**
|
||||
- Внутренние сигнатуры:
|
||||
- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется, сигнатура сохраняется.
|
||||
- `pr_already_merged(repo, branch) -> bool` — семантика/назначение уточняется.
|
||||
- `merge_pr(repo, branch) -> tuple[bool, str]` — поведение уточняется (фильтр code-PR).
|
||||
- (опц.) новая функция регресс-гарда в `merge_gate.py` — `tuple[bool, str]`/`bool`, never-raise.
|
||||
- `GET /queue` `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only,
|
||||
не источник истины).
|
||||
- Внешние вызовы Gitea — те же эндпоинты (`/pulls`, `/pulls/{index}/merge`).
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
|
||||
- **НЕТ.** Схема БД (`src/db.py`) не трогается (Не-цель). Регресс-гард опирается на git/`origin/main`,
|
||||
не на новые таблицы.
|
||||
|
||||
## 5. Требования к новым/изменённым QG checks
|
||||
|
||||
- **Новых зарегистрированных QG-checks не вводить.** Логика остаётся **под-гейтом** в
|
||||
`advance_stage` (`_handle_merge_verify`), как ORCH-071 — не новый элемент реестра `QG_CHECKS`.
|
||||
- Реестр `QG_CHECKS`, `check_deploy_status`, `_parse_deploy_status`, merge-gate
|
||||
(`check_branch_mergeable`), image-freshness — **без изменений**.
|
||||
|
||||
## 6. Конфигурация (`src/config.py` / `.env.example`)
|
||||
|
||||
- Существующие `merge_verify_enabled` (kill-switch, дефолт `true`), `merge_verify_repos` (пусто →
|
||||
только self-hosting), `merge_pr_timeout_s`, `merge_verify_timeout_s` — переиспользовать.
|
||||
- (опц., по дизайну) новые: kill-switch регресс-гарда и декларация набора маркеров. Дефолты —
|
||||
безопасные (для non-self — no-op). Любой новый ключ задокументировать в `.env.example`.
|
||||
|
||||
## 7. Артефакты pipeline, которые должны быть созданы/обновлены
|
||||
|
||||
- `docs/work-items/ORCH-073/06-adr/ADR-001-*.md` — решение по новой семантике merge-verify
|
||||
(FR-1/FR-2/FR-3) + регресс-гард (FR-5) + `.gitattributes` (FR-4).
|
||||
- `docs/architecture/README.md` — обновить раздел «Merge-в-main + пост-деплой верификация»
|
||||
(ORCH-071) под FR-1 (SHA как единственный критерий) и добавить регресс-гард FR-5.
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`.
|
||||
- `docs/work-items/ORCH-073/10-tech-risks.md`, `12-review.md`, `13-test-report.md`,
|
||||
`14-deploy-log.md`, `15-staging-log.md` — по ходу конвейера.
|
||||
- `04-test-plan.yaml` (этот пакет) — реализовать тесты в `tests/`.
|
||||
|
||||
## 8. Аудит G4 (зафиксировать в ADR / 06-adr)
|
||||
|
||||
Зафиксировать подтверждённую причину docs-only merge: у feature-ветки 067/069 в `main` попадали
|
||||
только авто docs-PR (staging-log / deploy-log / CLAUDE.md / CHANGELOG), а code-PR не сливался,
|
||||
при этом `pr_already_merged` засчитывал docs-PR → merge-verify ложно `CONFIRMED` → `done`.
|
||||
Корень устранён FR-1+FR-2+FR-3. Восстановление кода (G1) уже выполнено restore-PR #76 —
|
||||
подтвердить маркеры в `origin/main` (AC-1).
|
||||
77
docs/work-items/ORCH-073/03-acceptance-criteria.md
Normal file
77
docs/work-items/ORCH-073/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 03 — Критерии приёмки: ORCH-073
|
||||
|
||||
Каждый критерий — однозначный PASS/FAIL. Reviewer/Tester проверяют буквально.
|
||||
|
||||
## AC-1 — Код 067/069/071 одновременно в main (G1)
|
||||
`origin/main` содержит **одновременно**: `plane_issue_link` + кликабельный заголовок (ORCH-067),
|
||||
`qg0_title_max` (ORCH-069), `verify_merged_to_main` (ORCH-071).
|
||||
- **PASS:** все три маркера присутствуют, счётчики > 0:
|
||||
`git grep -c plane_issue_link origin/main -- src/notifications.py` > 0;
|
||||
`git grep -c qg0_title_max origin/main -- src/` > 0;
|
||||
`git grep -c verify_merged_to_main origin/main -- src/merge_gate.py` > 0.
|
||||
- **FAIL:** хотя бы один маркер == 0.
|
||||
|
||||
## AC-2 — `verify_merged_to_main` подтверждает merge ТОЛЬКО по SHA-в-main (FR-1)
|
||||
`verify_merged_to_main(repo, branch, sha)` возвращает `True` **только** когда `sha` — реальный
|
||||
предок `origin/main`.
|
||||
- **PASS:** unit-тест: `sha` НЕ в `main` → `False`, **даже если** существует merged docs-PR той же
|
||||
ветки (mock `pr_already_merged`/Gitea возвращает merged docs-PR). `sha` в `main` → `True`.
|
||||
- **FAIL:** функция возвращает `True` при `sha` не в `main` из-за merged docs-PR.
|
||||
|
||||
## AC-3 — Воспроизведение исходного бага → НЕ done + alert (FR-1/FR-2)
|
||||
Задача с merged **docs-PR**, но БЕЗ merged **code-PR** (SHA не в main): merge-verify НЕ
|
||||
`CONFIRMED`.
|
||||
- **PASS:** `_handle_merge_verify` возвращает HOLD (intervened) → задача остаётся на `deploy`,
|
||||
НЕ `done`, отправлен alert «not merged» (Telegram + Plane `set_issue_blocked`). Mock
|
||||
воспроизводит сценарий ORCH-067/069.
|
||||
- **FAIL:** задача доходит до `done` / нет alert.
|
||||
|
||||
## AC-4 — `.gitattributes CHANGELOG.md merge=union` (FR-4)
|
||||
В корне репо есть `.gitattributes` с `CHANGELOG.md merge=union`.
|
||||
- **PASS:** файл существует, `git check-attr merge CHANGELOG.md` → `merge: union`; тест: два
|
||||
последовательных ребейза/слияния с правкой `## [Unreleased]` НЕ дают конфликта, обе записи
|
||||
сохранены в результирующем `CHANGELOG.md`.
|
||||
- **FAIL:** атрибут отсутствует/не применяется ИЛИ возникает конфликт-маркер при ребейзе.
|
||||
|
||||
## AC-5 — Регресс-гард ловит откат соседнего кода (FR-5)
|
||||
После деплоя `main` без маркера ранее-merged задачи → alert, задача НЕ `done`.
|
||||
- **PASS:** тест: симуляция `main`, где deployed SHA есть, но набор маркеров уменьшился (или
|
||||
deployed SHA НЕ предок main) → `_handle_merge_verify` HOLD + alert «main regressed», НЕ `done`.
|
||||
- **FAIL:** регресс соседнего кода не пойман, задача `done`.
|
||||
|
||||
## AC-6 — Happy-path без ложных alert (INV-5 / AC-5 ТЗ)
|
||||
Код реально в `main` (deployed SHA — предок `origin/main`) → задача `done` штатно, без ложного
|
||||
alert; для non-self репо (enduro) merge/verify без изменений.
|
||||
- **PASS:** тест happy-path: SHA в main → `verify_merged_to_main`=`True`, `_handle_merge_verify`
|
||||
возвращает «advance» (не intervened); non-self репо → под-гейт no-op.
|
||||
- **FAIL:** ложный alert на корректном merge ИЛИ изменение поведения для enduro.
|
||||
|
||||
## AC-7 — Идемпотентность по SHA-в-main (INV-4)
|
||||
Повторный прогон/reaper уже-слитой задачи (SHA в main) → no-op, без второго merge.
|
||||
- **PASS:** тест: re-drive задачи с SHA-в-main → `merge_pr` no-op («already-merged»/idempotent),
|
||||
второго Gitea POST merge нет; задача остаётся `done`.
|
||||
- **FAIL:** второй merge / дубликат / ошибка.
|
||||
|
||||
## AC-8 — Документация и тесты обновлены (правило агентов §2/§6)
|
||||
- **PASS:** обновлены `CHANGELOG.md` (Unreleased), `docs/architecture/README.md` (раздел
|
||||
merge-verify под FR-1 + регресс-гард FR-5), создан ADR в `docs/work-items/ORCH-073/06-adr/`;
|
||||
pytest зелёный (`pytest tests/ -q`).
|
||||
- **FAIL:** доки/ADR не обновлены ИЛИ pytest красный.
|
||||
|
||||
## AC-9 — G4 аудит задокументирован
|
||||
Причина docs-only merge (code-PR не слит, `pr_already_merged` засчитал docs-PR) зафиксирована в
|
||||
ADR/06-adr, корень устранён (FR-1+FR-2+FR-3).
|
||||
- **PASS:** ADR содержит раздел «Root cause / G4 audit» с воспроизведением и устранением.
|
||||
- **FAIL:** аудит отсутствует.
|
||||
|
||||
## AC-10 — Воспроизведение на staging «исправлено навсегда» (G3/AC-9 ТЗ)
|
||||
2 задачи, обе с правкой `CHANGELOG.md`, прогнаны через staging → обе доезжают в `main` без потери
|
||||
кода друг друга.
|
||||
- **PASS:** зафиксировано в `15-staging-log.md`: оба набора маркеров присутствуют в `main` после
|
||||
обоих merge; ни одна правка CHANGELOG не вызвала конфликт/откат.
|
||||
- **FAIL:** код одной задачи затёрт другой ИЛИ конфликт CHANGELOG.
|
||||
|
||||
## AC-11 — self-hosting safety сохранена (INV-2/INV-3)
|
||||
- **PASS:** merge только через PR-API (без force-push в `main`); прод-контейнер не падал в рамках
|
||||
задачи; ручной `Confirm Deploy` сохранён.
|
||||
- **FAIL:** force-push в main / рестарт прод-контейнера в рамках merge / обход Confirm Deploy.
|
||||
117
docs/work-items/ORCH-073/04-test-plan.yaml
Normal file
117
docs/work-items/ORCH-073/04-test-plan.yaml
Normal file
@@ -0,0 +1,117 @@
|
||||
work_item: ORCH-073
|
||||
title: "CRIT: эрозия main — системный фикс merge-verify + восстановление кода 067/069"
|
||||
notes: >
|
||||
Покрытие FR-1..FR-5 / AC-1..AC-11. Все верификаторы — never-raise (INV-1):
|
||||
при ошибке git/HTTP → False (fail-closed), не падение. Gitea/git вызовы мокаются
|
||||
(monkeypatch httpx + subprocess), как в существующих тестах merge_gate/stage_engine.
|
||||
Тесты регресс-гарда и .gitattributes используют временный git-репозиторий (tmp_path).
|
||||
|
||||
tests:
|
||||
# ---- FR-1: verify_merged_to_main — SHA-в-main как единственный критерий ----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "verify_merged_to_main: sha — предок origin/main → True (happy-path, AC-6)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "verify_merged_to_main: sha НЕ предок main И существует merged docs-PR ветки → False (баг 067/069, AC-2)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "verify_merged_to_main: пустой sha → False (неопределённо, fail-closed)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "verify_merged_to_main: git fetch/merge-base бросает исключение → False (never-raise, INV-1)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-2: pr_already_merged различает code-PR / docs-PR ----
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "pr_already_merged/идентификация PR: merged docs-PR (head=docs/*, base=main) НЕ засчитывается как merge кода ветки."
|
||||
module: tests/test_orch073_pr_classify.py
|
||||
expected: PASS
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "merged code-PR (head=<feature-branch>, base=main) корректно распознаётся как code-merge."
|
||||
module: tests/test_orch073_pr_classify.py
|
||||
expected: PASS
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "pr_already_merged: HTTP-ошибка/не-200 → False (never-raise, консервативно)."
|
||||
module: tests/test_orch073_pr_classify.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-3: merge_pr сливает именно code-ветку ----
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "merge_pr выбирает open PR с head==<feature-branch> и base==main (не docs/*), вызывает Gitea POST merge."
|
||||
module: tests/test_orch073_merge_pr.py
|
||||
expected: PASS
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "merge_pr: нет open code-PR → (False, 'no open PR'); никогда не push/force-push main (INV-2)."
|
||||
module: tests/test_orch073_merge_pr.py
|
||||
expected: PASS
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "merge_pr идемпотентен: уже-слитый code-PR (SHA в main) → no-op, без второго POST merge (AC-7/INV-4)."
|
||||
module: tests/test_orch073_merge_pr.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-4: .gitattributes CHANGELOG.md merge=union ----
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: ".gitattributes в корне репо содержит 'CHANGELOG.md merge=union'; git check-attr подтверждает driver=union (AC-4)."
|
||||
module: tests/test_orch073_gitattributes.py
|
||||
expected: PASS
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "Во временном git-репо два ребейза/слияния с правкой '## [Unreleased]' НЕ дают конфликта; обе записи в CHANGELOG сохранены (AC-4)."
|
||||
module: tests/test_orch073_gitattributes.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-5: регресс-гард целостности main + интеграция в _handle_merge_verify ----
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "_handle_merge_verify: SHA в main И маркеры на месте → return False (advance к done, happy-path AC-6)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "_handle_merge_verify: SHA НЕ в main (docs-only merge) → return True (HOLD), alert + set_issue_blocked, НЕ done (AC-3)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Регресс-гард: deployed SHA есть, но набор маркеров ранее-merged задач уменьшился → HOLD + alert 'main regressed', НЕ done (AC-5)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "_handle_merge_verify: внутренняя ошибка верификатора → HOLD + alert, без проброса исключения в advance_stage (never-raise, INV-1)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Условность / обратная совместимость ----
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "merge_verify_applies: non-self репо (enduro) или kill-switch off → под-гейт no-op, поведение merge/verify без изменений (AC-6/INV-5)."
|
||||
module: tests/test_orch073_conditionality.py
|
||||
expected: PASS
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "Регресс-гард уважает kill-switch (merge_verify_enabled=False) → no-op; для non-self → no-op (INV-5)."
|
||||
module: tests/test_orch073_conditionality.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Регресс существующего поведения ----
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "Существующие тесты merge_gate/stage_engine (ORCH-065/071) остаются зелёными; полный pytest tests/ -q green (AC-8)."
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,214 @@
|
||||
# ADR-001 (ORCH-073): SHA-в-main как единственный критерий merge-verify + регресс-гард + `.gitattributes`
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-08
|
||||
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
|
||||
- **Связь:** усиливает/чинит ORCH-071 (merge-verify под-гейт). Сквозной аналог — `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md` (amends adr-0013).
|
||||
- **Источники:** `01-brd.md` (root-cause git-аудит 08.06), `02-trz.md` (FR-1…FR-5), `03-acceptance-criteria.md` (AC-1…AC-11).
|
||||
|
||||
## Контекст
|
||||
|
||||
Код «задеплоенных» и переведённых в `done` задач **ORCH-067** (`plane_issue_link`, кликабельные
|
||||
ссылки, tracker bump) и **ORCH-069** (`qg0_title_max`) физически отсутствовал в `origin/main`,
|
||||
хотя обе прошли весь конвейер, Confirm Deploy, merge-verify `CONFIRMED` и стали `done`. В `main`
|
||||
попадали только их **docs-коммиты** (staging/deploy-логи через отдельные авто docs-PR), но НЕ
|
||||
код feature-веток. Внешнее проявление (нашёл Слава, 08.06): в карточке Telegram сырой номер
|
||||
задачи вместо кликабельной ссылки — код ссылок есть в ветке ORCH-067, но не в `main`.
|
||||
|
||||
### Root cause (G4 audit) — подтверждён git-аудитом, НЕ гипотеза
|
||||
|
||||
1. **`verify_merged_to_main` подтверждает merge по ложному признаку.** Возвращает `True`, если
|
||||
`pr_already_merged(repo, branch)` **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
|
||||
OR-ветка `pr_already_merged` — и есть дыра.
|
||||
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR.** `GET /pulls?state=all&head=<branch>` и
|
||||
`True`, если **хоть один** PR `merged==True`. Параметр `head` у Gitea для одиночной строки-ветки
|
||||
фильтрует ненадёжно → в выборку попадают авто docs-PR (staging/deploy-логи) с других веток
|
||||
(`docs/*`). Сливается docs-PR → `pr_already_merged`=`True` → `verify_merged_to_main`=`True` →
|
||||
merge-verify `CONFIRMED` → `done`, хотя **code-PR НЕ слит**. Ложно-зелёный.
|
||||
3. **CHANGELOG-ребейзы — вторичный усилитель.** `auto_rebase_onto_main` при конфликте
|
||||
`CHANGELOG.md` откатывает `deploy-staging → development`; повторный ребейз ветки от старого
|
||||
`main` несёт устаревшие версии соседних файлов, которые при merge тихо затирают код-сосед
|
||||
(фантом-эффект как в ORCH-071, без конфликт-маркеров).
|
||||
|
||||
**G1 (восстановление кода) выполнено вручную** restore-PR #76 — `git grep` подтверждает в
|
||||
`origin/main` одновременно `plane_issue_link` (8), `qg0_title_max` (3+2), `verify_merged_to_main`
|
||||
(4). ORCH-073 фиксирует это в AC-1 и устраняет корень навсегда (FR-1…FR-5).
|
||||
|
||||
## Решение
|
||||
|
||||
Меняется **семантика merge-verify** (под-гейт ребра `deploy → done`, врезка `_handle_merge_verify`
|
||||
в `advance_stage`, введён ORCH-071). `STAGE_TRANSITIONS`, реестр `QG_CHECKS`,
|
||||
`check_deploy_status`/`_parse_deploy_status`, merge-gate (`check_branch_mergeable`),
|
||||
image-freshness, схема БД (`src/db.py`) — **НЕ меняются**. Внешние HTTP-эндпоинты `src/main.py` —
|
||||
**НЕ меняются**.
|
||||
|
||||
### Р-1 (FR-1, ядро) — `verify_merged_to_main`: SHA-в-main — единственный критерий
|
||||
|
||||
Подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком `origin/main`»:
|
||||
|
||||
```
|
||||
verify_merged_to_main(repo, branch, sha) -> bool:
|
||||
if not sha: # пустой SHA -> неопределённо
|
||||
log warning; return False # fail-closed (alert + HOLD)
|
||||
git fetch origin main (timeout merge_verify_timeout_s)
|
||||
rc = git merge-base --is-ancestor <sha> origin/main
|
||||
return rc == 0
|
||||
```
|
||||
|
||||
- **OR-ветка `pr_already_merged` удаляется** из `verify_merged_to_main`. PR-флаг больше **не
|
||||
подтверждает** merge.
|
||||
- Пустой `sha` → `False` (fail-closed: alert + HOLD), как сейчас.
|
||||
- never-raise: любая git-ошибка → `False` (INV-1) — фейл-клозед для `done`.
|
||||
|
||||
> Дизайн-выбор: вариант (б) из ТЗ §2 FR-2 — единственный источник истины «merged/done» — это
|
||||
> SHA-в-main. PR-флаги остаются только как **idempotency-guard** в `merge_pr` (Р-3), не как
|
||||
> подтверждение.
|
||||
|
||||
### Р-2 (FR-2/G2) — `pr_already_merged`: различает code-PR и docs-PR
|
||||
|
||||
`pr_already_merged` понижается до **idempotency-guard для `merge_pr`** (не источник истины для
|
||||
`done`). Но guard обязан быть **корректным**: «слит ли именно code-PR ЭТОЙ ветки», иначе merged
|
||||
docs-PR заставил бы `merge_pr` ошибочно сделать no-op и пропустить реальный merge кода.
|
||||
Поэтому в цикле явный фильтр (НЕ полагаться на ненадёжный query-параметр `head`):
|
||||
|
||||
```
|
||||
for pr in resp.json():
|
||||
if pr.merged is True
|
||||
and pr.head.ref == branch # код именно этой feature-ветки
|
||||
and pr.base.ref == "main": # таргет — main, не docs-база
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
- Исключает авто docs-PR (другой `head.ref`, напр. `docs/*`) и PR на не-`main` базу.
|
||||
- never-raise → `False` (консервативно).
|
||||
- Поведение для non-self репо (enduro) не меняется (INV-5) — `merge_pr`/verify для них как раньше.
|
||||
|
||||
### Р-3 (FR-3/G2) — `merge_pr`: сливает именно code-ветку
|
||||
|
||||
`merge_pr` уже выбирает открытый PR по `head.ref==branch`; добавляется фильтр `base.ref=="main"`
|
||||
при выборе PR (защита от слияния PR на чужую базу). Idempotency-guard `pr_already_merged` (Р-2,
|
||||
теперь корректный) перед merge оставляем — повторный прогон не делает второй POST. Merge —
|
||||
ТОЛЬКО Gitea `POST /pulls/{index}/merge`, никогда push/force-push в `main` (INV-2). После merge
|
||||
единственный источник истины «слилось» — FR-1 (SHA-в-main), его проверяет `_handle_merge_verify`.
|
||||
|
||||
### Р-4 (FR-5/G5) — регресс-гард целостности `main` (защита навсегда)
|
||||
|
||||
Новая детерминированная (no-LLM) функция в `merge_gate.py`, вызывается в `_handle_merge_verify`
|
||||
**ПОСЛЕ** подтверждённого SHA-в-main (Р-1) и **ДО** `update_task_stage(done)`:
|
||||
|
||||
```
|
||||
check_main_regression(repo, branch) -> tuple[bool, str]
|
||||
# ok=True -> регресса нет (набор маркеров цел) -> пропустить к done
|
||||
# ok=False -> маркер отсутствует -> "main regressed: <task/marker> missing"
|
||||
```
|
||||
|
||||
**Декларативный набор маркеров** — константа в `merge_gate.py` (append-only, расширяется каждой
|
||||
будущей задачей; НЕ БД, НЕ Plane — Не-цель):
|
||||
|
||||
```python
|
||||
MAIN_REGRESSION_MARKERS = [
|
||||
# (task, marker_substring, path)
|
||||
("ORCH-067", "plane_issue_link", "src/notifications.py"),
|
||||
("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"),
|
||||
]
|
||||
```
|
||||
|
||||
Проверка (в worktree после `git fetch origin main`): для каждого маркера
|
||||
`git grep -c <marker> origin/main -- <path>`; счётчик `0` → регресс.
|
||||
|
||||
- **Реакция при регрессе: ALERT-only + HOLD** (`set_issue_blocked` + Telegram + Plane-коммент
|
||||
«main regressed: code of `<task>` missing»), задача **НЕ `done`**, остаётся на `deploy`. БЕЗ
|
||||
авто-отката на `development` (это инфра-дефект, не код-фолт), симметрично not-merged ветке
|
||||
ORCH-071.
|
||||
- **Fail-OPEN на инфра-ошибке грепа** (намеренный trade-off): любая git/OS-ошибка самого грепа →
|
||||
`(True, "guard inconclusive: …")` → НЕ блокировать `done`. Обоснование: первичный фейл-клозед
|
||||
гейт — это SHA-в-main (Р-1); вторичный marker-grep не должен давать ложный HOLD на git-сбое.
|
||||
«Регресс» утверждается только при **детерминированном `count==0`**, не при «не смог определить».
|
||||
- never-raise (INV-1). Kill-switch — новый `regression_guard_enabled` (дефолт `true`,
|
||||
переиспользует область self-hosting через `merge_verify_applies`). Non-self репо — no-op (INV-5).
|
||||
|
||||
### Р-5 (FR-4/G4 корень) — `.gitattributes` с `merge=union`
|
||||
|
||||
В корне репозитория новый файл `.gitattributes`:
|
||||
|
||||
```
|
||||
CHANGELOG.md merge=union
|
||||
```
|
||||
|
||||
- `merge=union` — встроенный git-драйвер, доп. конфиг хоста не требуется; проверяется
|
||||
`git check-attr merge CHANGELOG.md` → `merge: union`.
|
||||
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
|
||||
сохраняются) без конфликт-маркера → ветка не откатывается в `development` и не тащит устаревшие
|
||||
версии соседних файлов.
|
||||
- **Решено НЕ добавлять `docs/**/*.md merge=union`:** union годится только для строго
|
||||
append-only файлов; docs-артефакты (README, ADR, internals) регулярно **переписываются**
|
||||
построчно — union там тихо задублировал бы строки. Ограничиваемся `CHANGELOG.md`.
|
||||
- Оговорка о самозагрузке: задача, ВПЕРВЫЕ вносящая `.gitattributes`, при собственном ребейзе
|
||||
ещё не получает эффект union (атрибут попадёт в `main` только после её merge). Это допустимо —
|
||||
гард действует для всех последующих задач.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
| Ключ | Дефолт | Назначение |
|
||||
|---|---|---|
|
||||
| `merge_verify_enabled` (есть) | `true` | kill-switch всего под-гейта |
|
||||
| `merge_verify_repos` (есть) | `""` | CSV; пусто → только self-hosting |
|
||||
| `merge_pr_timeout_s` / `merge_verify_timeout_s` (есть) | `60` | таймауты Gitea/git |
|
||||
| `regression_guard_enabled` (новый) | `true` | kill-switch регресс-гарда (Р-4); non-self → no-op |
|
||||
|
||||
Новый ключ задокументировать в `.env.example`. Дефолты безопасны (для non-self — no-op).
|
||||
|
||||
## Сигнатуры (внутренние; внешний API не меняется)
|
||||
|
||||
- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется (Р-1), сигнатура та же.
|
||||
- `pr_already_merged(repo, branch) -> bool` — назначение/фильтр уточняются (Р-2), сигнатура та же.
|
||||
- `merge_pr(repo, branch) -> tuple[bool, str]` — фильтр `base==main` (Р-3), сигнатура та же.
|
||||
- `check_main_regression(repo, branch) -> tuple[bool, str]` — **новая**, never-raise, fail-open.
|
||||
- `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only, не источник истины).
|
||||
|
||||
## Инварианты
|
||||
|
||||
- **INV-1** never-raise: ошибка верификации → alert/HOLD, не падение конвейера.
|
||||
- **INV-2** self-hosting safety: прод 8500 не падает/не рестартится в рамках merge; merge только
|
||||
Gitea PR-API, без force-push в `main`.
|
||||
- **INV-3** ручной `Confirm Deploy` (ORCH-059) сохранён.
|
||||
- **INV-4** идемпотентность опирается на «SHA-в-main», а не на «любой merged PR».
|
||||
- **INV-5** обратная совместимость non-self (enduro): merge/verify/регресс-гард — no-op.
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
1. **Оставить `pr_already_merged` как со-критерий verify, но фильтровать по `head/base`** —
|
||||
отклонено: PR-флаг всё равно слабее факта «SHA в main» (PR можно слить и тут же откатить
|
||||
ребейзом-соседом). Единственный надёжный критерий — предок-`main`. PR-флаг → только idempotency.
|
||||
2. **`docs/**/*.md merge=union`** — отклонено (см. Р-5): тихая дубликация строк в переписываемых
|
||||
доках.
|
||||
3. **Регресс-гард с авто-откатом на `development`** — отклонено: регресс соседнего кода —
|
||||
инфра-дефект merge, не код-фолт текущей задачи; реакция ALERT-only + HOLD (как ORCH-021/071).
|
||||
4. **Хранить набор маркеров в БД/Plane** — отклонено (Не-цель «не менять схему БД/Plane»);
|
||||
декларативная append-only константа в коде проще и версионируется вместе с фиксом.
|
||||
5. **Fail-closed на marker-grep** — отклонено: дало бы ложный HOLD при git-сбое; первичный
|
||||
фейл-клозед — SHA-в-main (Р-1), marker-grep вторичен → fail-open.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюс:** невозможно «`done` + прод задеплоен, а code-PR не в `main`» — единственный критерий
|
||||
`done` теперь «SHA-в-main». Ложно-зелёный по docs-PR устранён в корне (Р-1+Р-2+Р-3).
|
||||
- **Плюс:** CHANGELOG-конфликты больше не откатывают ветку и не тащат устаревший код-сосед (Р-5).
|
||||
- **Плюс:** регресс-гард ловит откат соседнего кода даже если SHA-в-main прошёл (Р-4).
|
||||
- **Минус:** при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
|
||||
(снимается повтором; fail-closed для `done` приоритетен). Регресс-гард при git-сбое наоборот
|
||||
fail-open (не блокирует) — осознанный trade-off, SHA-в-main остаётся первичным гейтом.
|
||||
- **Минус:** набор маркеров требует дисциплины — каждая значимая задача дописывает свой маркер
|
||||
(иначе гард его не защитит). Документируется в `CLAUDE.md`/README.
|
||||
|
||||
## Связи
|
||||
|
||||
- Amends: `docs/architecture/adr/adr-0013-merge-verify-gate.md` (ORCH-071) — меняет критерий verify.
|
||||
- Сквозной: `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md`.
|
||||
- Постмортем: `docs/history/LESSONS_2026-06-08_phantom-merge.md`, runbook
|
||||
`docs/operations/PHANTOM_MERGE_RUNBOOK.md`.
|
||||
- AC: AC-1 (G1 markers), AC-2/AC-3 (Р-1/Р-2), AC-4 (Р-5), AC-5 (Р-4), AC-6 (happy-path),
|
||||
AC-7 (idempotency), AC-8/AC-9 (docs+audit), AC-10 (staging), AC-11 (self-hosting safety).
|
||||
32
docs/work-items/ORCH-073/07-infra-requirements.md
Normal file
32
docs/work-items/ORCH-073/07-infra-requirements.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 07 — Инфра-требования: ORCH-073
|
||||
|
||||
## Топология
|
||||
**Без изменений.** Один сервер (mva154), prod `orchestrator` (8500), staging
|
||||
`orchestrator-staging` (8501), общая SQLite, общая очередь. Новых контейнеров/портов/сервисов нет.
|
||||
|
||||
## Git / worktree
|
||||
- Новый корневой файл **`.gitattributes`** (`CHANGELOG.md merge=union`). Драйвер `union` —
|
||||
встроенный в git, **доп. конфигурация хоста НЕ требуется**.
|
||||
- Проверка применения в worktree агентов: `git check-attr merge CHANGELOG.md` → `merge: union`.
|
||||
Атрибут действует при 3-way merge/rebase, когда `.gitattributes` присутствует в дереве
|
||||
(`auto_rebase_onto_main` выполняет `git rebase origin/main` в per-branch worktree).
|
||||
- Самозагрузка: первая задача с `.gitattributes` своего ребейза не ускоряет (атрибут попадёт в
|
||||
`main` после её merge); эффект — для последующих задач. Допустимо.
|
||||
- Регресс-гард (`check_main_regression`) использует уже существующий per-branch worktree
|
||||
(`ensure_worktree` + `git fetch origin main` + `git grep origin/main`). Новых клонов/worktree нет.
|
||||
|
||||
## Сеть / внешние интеграции
|
||||
- Те же Gitea-эндпоинты: `GET /pulls`, `POST /pulls/{index}/merge`. Новых внешних вызовов нет.
|
||||
- Telegram/Plane — существующие хелперы alert (`send_telegram`, `set_issue_blocked`,
|
||||
`plane_add_comment`). Новых интеграций нет.
|
||||
|
||||
## Деплой self (self-hosting safety)
|
||||
- Прод-контейнер `orchestrator` (8500) **НЕ рестартить/не ронять** в рамках задачи.
|
||||
- Обязательный staging-гейт (8501) перед прод-деплоем; прод-деплой — только переводом на
|
||||
`Confirm Deploy` (ORCH-059). Ручной гейт не меняется.
|
||||
- Merge — только Gitea PR-API, без force-push в `main`.
|
||||
|
||||
## Конфигурация (хост `.env` / `.env.example`)
|
||||
- Новый ключ `regression_guard_enabled` (дефолт `true`) — задокументировать в `.env.example`.
|
||||
- Существующие `merge_verify_enabled`/`merge_verify_repos`/`merge_pr_timeout_s`/
|
||||
`merge_verify_timeout_s` — переиспользуются, без изменений значений.
|
||||
23
docs/work-items/ORCH-073/08-data-requirements.md
Normal file
23
docs/work-items/ORCH-073/08-data-requirements.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 08 — Требования к данным/схеме БД: ORCH-073
|
||||
|
||||
## Схема БД
|
||||
**Без изменений.** `src/db.py` не трогается (Не-цель BRD §5, ТЗ §4). Новых таблиц/колонок/
|
||||
миграций нет.
|
||||
|
||||
## Источник истины merge-verify
|
||||
- Подтверждение `done` опирается **только на git** (`origin/main`: `git merge-base
|
||||
--is-ancestor <sha> origin/main`), НЕ на состояние БД и НЕ на Plane-статусы.
|
||||
- Регресс-гард (`check_main_regression`) опирается на `git grep origin/main` по декларативному
|
||||
набору маркеров — **не на БД**.
|
||||
- Набор маркеров `MAIN_REGRESSION_MARKERS` — **append-only константа в коде** (`src/merge_gate.py`),
|
||||
версионируется вместе с фиксом. Сознательно НЕ в БД и НЕ в Plane (Не-цель).
|
||||
|
||||
## Состояние в БД (читается, не меняется)
|
||||
- `tasks.stage` — переходы через существующий `update_task_stage`/`advance_stage`; HOLD = задача
|
||||
остаётся на `deploy` (не записывается `done`). Семантика та же, что у ORCH-071.
|
||||
- Счётчики `_MERGE_VERIFY_COUNTERS` — **in-process**, не БД; read-only через `GET /queue`.
|
||||
Допустимо дополнить счётчиком регресс-алертов (наблюдаемость, не источник истины).
|
||||
|
||||
## Plane
|
||||
**Без изменений** (Не-цель). Используются существующие сеттеры (`set_issue_blocked`,
|
||||
`plane_add_comment`) для alert/HOLD. Новых статусов/маппингов нет.
|
||||
19
docs/work-items/ORCH-073/10-tech-risks.md
Normal file
19
docs/work-items/ORCH-073/10-tech-risks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 10 — Технические риски: ORCH-073
|
||||
|
||||
| # | Риск | Вероятность | Влияние | Митигация |
|
||||
|---|------|-------------|---------|-----------|
|
||||
| R-1 | **Ложный HOLD на сбое Gitea/git** — verify консервативно `False` при недоступности → задача не доходит до `done`, нужен повтор. | средняя | среднее | Осознанный fail-closed для `done` (приоритет: не дать ложно-зелёный). Снимается re-drive (reaper/reconciler/re-approve). Документировано в ADR «Последствия». |
|
||||
| R-2 | **`pr_already_merged` всё ещё ловит docs-PR** при иной структуре head/base в Gitea (cross-repo `owner:branch`). | низкая | высокое (возврат бага) | Явный фильтр в цикле `head.ref==branch И base.ref=="main"` (не полагаться на query-param). Тест AC-2/AC-3 мокает merged docs-PR и проверяет, что verify=`False`. |
|
||||
| R-3 | **Регресс-гард fail-open пропустит реальный регресс** во время git-сбоя грепа. | низкая | среднее | Первичный гейт `done` — SHA-в-main (fail-closed). Marker-grep вторичен; «регресс» — только при детерминированном `count==0`. Trade-off зафиксирован в ADR. |
|
||||
| R-4 | **Набор маркеров устаревает/неполный** — будущая задача не добавила свой маркер → гард её не защищает. | средняя | среднее | Append-only константа в коде + правило в `CLAUDE.md`/README «значимая задача дописывает маркер». Reviewer проверяет. Не регресс существующего поведения (только недозащита нового). |
|
||||
| R-5 | **`merge=union` тихо дублирует строки** при применении к не-append-only файлам. | низкая | среднее | Union строго ограничен `CHANGELOG.md`; `docs/**` под union НЕ ставится (решение Р-5 ADR). |
|
||||
| R-6 | **Самозагрузка `.gitattributes`** — первая задача не получает эффект union на своём ребейзе. | высокая (одноразово) | низкое | Принято: атрибут попадёт в `main` после merge ORCH-073, действует для последующих задач. Для самой ORCH-073 CHANGELOG-конфликт разрешается вручную при необходимости. |
|
||||
| R-7 | **Ложный «main regressed» при легитимном рефакторе**, переименовавшем маркер-функцию. | низкая | среднее | Маркеры выбираются как стабильные публичные имена; при намеренном переименовании задача обновляет `MAIN_REGRESSION_MARKERS` в том же PR (правило документации). |
|
||||
| R-8 | **Регресс на non-self репо (enduro)** из-за нового кода. | низкая | высокое | Вся врезка под `merge_verify_applies` (kill-switch + self-hosting scope); регресс-гард — отдельный `regression_guard_enabled`; non-self → no-op (INV-5). Тест AC-6 (enduro no-op). |
|
||||
| R-9 | **Self-hosting: рестарт/падение прода** при ошибке в merge_gate. | низкая | высокое (групповой риск) | never-raise контракт (INV-1); merge только PR-API без force-push; staging-гейт обязателен; прод не рестартится в рамках merge. Тест AC-11. |
|
||||
|
||||
## Сводный вывод
|
||||
Изменения локализованы в `src/merge_gate.py` + врезка в `_handle_merge_verify`
|
||||
(`src/stage_engine.py`) + новый ключ конфигурации + корневой `.gitattributes`. Схема БД, Plane,
|
||||
внешние HTTP-эндпоинты, реестр QG, `STAGE_TRANSITIONS` — не затронуты. Главный остаточный риск —
|
||||
ложный HOLD на инфра-сбое (R-1), сознательно принят ради устранения ложно-зелёного merge-verify.
|
||||
75
docs/work-items/ORCH-073/12-review.md
Normal file
75
docs/work-items/ORCH-073/12-review.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-073
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-073
|
||||
|
||||
## Summary
|
||||
Системный фикс эрозии `main` (фантомный merge ORCH-067/069) реализован строго по
|
||||
ТЗ (FR-1…FR-5) и ADR-001. Все 11 критериев приёмки выполнены, документация обновлена
|
||||
в том же PR, `pytest tests/ -q` → **941 passed**. Self-hosting-инварианты соблюдены
|
||||
(merge только через Gitea PR-API, без force-push в `main`; non-self репо — no-op).
|
||||
Блокирующих и must-fix замечаний нет.
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
### 1. Соответствие ТЗ (02-trz.md)
|
||||
- **FR-1** — `verify_merged_to_main` подтверждает merge ТОЛЬКО `git merge-base --is-ancestor <sha> origin/main`; OR-ветка `pr_already_merged` удалена; пустой SHA / git-ошибка → `False` (fail-closed, never-raise). ✓
|
||||
- **FR-2** — `pr_already_merged` понижен до idempotency-guard, явный in-loop фильтр `merged & head.ref==branch & base.ref=="main"` (не ненадёжный query `head`). ✓
|
||||
- **FR-3** — `merge_pr` выбирает open PR по `head.ref==branch` И `base.ref=="main"`; merge только `POST /pulls/{n}/merge`. ✓
|
||||
- **FR-4** — корневой `.gitattributes` с `CHANGELOG.md merge=union`; `docs/**` намеренно НЕ включён. ✓
|
||||
- **FR-5** — `check_main_regression` (детерминированный, no-LLM) + декларативный append-only `MAIN_REGRESSION_MARKERS`; вызов в `_handle_merge_verify` ПОСЛЕ SHA-в-main и ДО `done`; ALERT-only + HOLD; fail-open на git-ошибке грепа; kill-switch `regression_guard_enabled`. ✓
|
||||
|
||||
### 2. Соответствие ADR (06-adr/ADR-001 + adr-0014)
|
||||
Реализация 1:1 соответствует Р-1…Р-5. G4-аудит и root-cause зафиксированы в ADR
|
||||
(раздел «Root cause (G4 audit)»). Сквозной ADR-0014 заведён, `adr/README.md` обновлён,
|
||||
`adr-0013` помечен как amended. Нарушений глобальных ADR не обнаружено.
|
||||
**AC-1 подтверждён в `origin/main`:** `plane_issue_link`(8), `qg0_title_max`(config.py 3),
|
||||
`verify_merged_to_main`(4). **AC-4 подтверждён:** `git check-attr merge CHANGELOG.md → merge: union`.
|
||||
|
||||
### 3. Качество кода
|
||||
- Строгий never-raise на всех публичных функциях merge_gate; INV-1…INV-5 соблюдены.
|
||||
- Docstrings содержательные, со ссылками на FR/AC/INV; обоснован осознанный trade-off
|
||||
fail-open для marker-grep против fail-closed SHA-в-main.
|
||||
- `_hold_main_regressed` симметричен not-merged-HOLD; уведомления Plane/Telegram best-effort,
|
||||
не ломают HOLD.
|
||||
- Схема БД, реестр `QG_CHECKS`, `STAGE_TRANSITIONS`, внешние HTTP-эндпоинты — не тронуты (как и заявлено).
|
||||
|
||||
### 4. Качество тестов
|
||||
18 тест-кейсов (TC-01…18) в 6 файлах `tests/test_orch073_*.py`, не тривиальные:
|
||||
- TC-02 воспроизводит исходный баг (merged docs-PR не подтверждает merge), проверяет, что
|
||||
PR-флаг verify-ом более не запрашивается.
|
||||
- TC-14/15 различают HOLD по «not-merged» и по «main-regressed».
|
||||
- TC-10 — идемпотентность (нет второго POST merge). TC-17/18 — conditionality/kill-switch.
|
||||
- TC-12 в throwaway-репо реально проверяет union-merge без конфликта.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- Маркер `("ORCH-073", "check_main_regression", "src/merge_gate.py")` самозагрузочный
|
||||
(попадёт в `origin/main` только после merge этой задачи) — поведение корректное и
|
||||
оговорено в ADR (self-bootstrap), замечание чисто информационное.
|
||||
|
||||
## Документация
|
||||
Полностью обновлена в этом же PR (правило агентов §2/§6, AC-8):
|
||||
- `docs/architecture/README.md` — раздел merge-verify переписан под FR-1 + добавлены регресс-гард (FR-5) и `.gitattributes` (FR-4).
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`.
|
||||
- `docs/work-items/ORCH-073/06-adr/ADR-001-*.md` — новый ADR с G4-аудитом; `docs/architecture/adr/adr-0014-*.md` — сквозной ADR; `adr/README.md` обновлён.
|
||||
- `.env.example` — задокументирован новый ключ `ORCH_REGRESSION_GUARD_ENABLED` + блок merge-verify.
|
||||
|
||||
Требование «изменён `src/` → обновлена документация» выполнено. Блокеров по документации нет.
|
||||
|
||||
## Вердикт
|
||||
**APPROVED** — нет P0/P1; код, тесты и документация соответствуют ТЗ/ADR; self-hosting-страховки сохранены.
|
||||
83
docs/work-items/ORCH-073/13-test-report.md
Normal file
83
docs/work-items/ORCH-073/13-test-report.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-073
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-073
|
||||
|
||||
CRIT: системный фикс эрозии `main` (фантомный merge ORCH-067/069) + восстановление кода.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Дата: 2026-06-08
|
||||
- Worktree: `feature/ORCH-073-crit-main-orch-067-069`
|
||||
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — контейнер не тронут
|
||||
|
||||
## Smoke-тесты API (prod 8500, read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok"}` — PASS |
|
||||
| `GET /status` | active_tasks отдаётся, ORCH-073 на стадии `testing` — PASS |
|
||||
| `GET /queue` | counts/reconcile/reaper/post_deploy снимок отдаётся, breaker `closed` — PASS |
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-01 | verify_merged_to_main: sha — предок main → True (AC-6) | test_tc01_true_when_sha_is_ancestor | PASS |
|
||||
| TC-02 | sha НЕ в main + merged docs-PR → False (баг 067/069, AC-2) | test_tc02_false_when_sha_not_in_main_even_with_merged_docs_pr | PASS |
|
||||
| TC-03 | пустой sha → False (fail-closed) | test_tc03_empty_sha_is_false | PASS |
|
||||
| TC-04 | git error → False (never-raise, INV-1) | test_tc04_never_raises_on_git_error / _worktree_error | PASS |
|
||||
| TC-05 | merged docs-PR не засчитан как code-merge (FR-2) | test_tc05_merged_docs_pr_not_counted | PASS |
|
||||
| TC-06 | merged code-PR распознан (base=main, head=branch) | test_tc06_merged_code_pr_recognised / _onto_non_main_base_not_counted | PASS |
|
||||
| TC-07 | HTTP-ошибка/не-200 → False (never-raise) | test_tc07_non_200_is_false / _http_exception_is_false | PASS |
|
||||
| TC-08 | merge_pr выбирает code-PR, не docs/* (FR-3) | test_tc08_merges_code_pr_not_docs_pr / _skips_pr_onto_non_main_base | PASS |
|
||||
| TC-09 | нет open code-PR → (False,...), без push main (INV-2) | test_tc09_no_open_pr_no_shell_out | PASS |
|
||||
| TC-10 | merge_pr идемпотентен, без второго POST (AC-7/INV-4) | test_tc10_idempotent_already_merged | PASS |
|
||||
| TC-11 | .gitattributes: CHANGELOG.md merge=union (AC-4) | test_tc11_gitattributes_declares_union | PASS |
|
||||
| TC-12 | union-merge сохраняет обе записи Unreleased без конфликта | test_tc12_union_merge_keeps_both_entries | PASS |
|
||||
| TC-13 | _handle_merge_verify: SHA в main + маркеры → advance (AC-6) | test_tc13_confirmed_and_intact_advances | PASS |
|
||||
| TC-14 | docs-only merge → HOLD + alert, НЕ done (AC-3) | test_tc14_sha_not_in_main_holds | PASS |
|
||||
| TC-15 | регресс-гард: маркер ранее-merged задачи пропал → HOLD + alert (AC-5) | test_tc15_marker_missing_holds | PASS |
|
||||
| TC-16 | внутр. ошибка верификатора → HOLD + alert, never-raise (INV-1) | test_tc16_internal_error_holds_never_raises | PASS |
|
||||
| TC-17 | conditionality: non-self/kill-switch → под-гейт no-op (AC-6/INV-5) | test_tc17_merge_verify_applies_scope / _under_gate_noop_for_non_self | PASS |
|
||||
| TC-18 | регресс-гард уважает kill-switch / non-self → no-op (INV-5) | test_tc18_guard_kill_switch_skips_guard / _guard_noop_for_non_self_repo | PASS |
|
||||
| TC-19 | полный pytest tests/ -q зелёный (AC-8) | весь набор tests/ | PASS |
|
||||
|
||||
Все 19 TC из тест-плана покрыты (24 тест-функции в 6 файлах `tests/test_orch073_*.py`).
|
||||
|
||||
## Проверка критериев приёмки (03-acceptance-criteria.md)
|
||||
|
||||
| AC | Проверка | Результат |
|
||||
|----|----------|-----------|
|
||||
| AC-1 | Маркеры в origin/main: plane_issue_link=8, qg0_title_max=3, verify_merged_to_main=4 (все >0) | PASS |
|
||||
| AC-2 | TC-02: sha не в main + merged docs-PR → False | PASS |
|
||||
| AC-3 | TC-14: docs-only merge → HOLD + alert, НЕ done | PASS |
|
||||
| AC-4 | `git check-attr merge CHANGELOG.md` → `merge: union`; TC-11/12 | PASS |
|
||||
| AC-5 | TC-15: уменьшение набора маркеров → HOLD + alert «main regressed» | PASS |
|
||||
| AC-6 | TC-01/13/17: happy-path done без ложного alert; enduro no-op | PASS |
|
||||
| AC-7 | TC-10: re-drive слитой задачи → no-op, без второго merge | PASS |
|
||||
| AC-8 | 941 passed; доки/ADR/CHANGELOG обновлены (см. 12-review) | PASS |
|
||||
| AC-9 | G4-аудит в ADR-001 (root cause docs-only merge) — подтверждён reviewer | PASS |
|
||||
| AC-10 | staging-проверка — стадия deploy-staging (вне scope tester) | — |
|
||||
| AC-11 | merge только PR-API; прод-контейнер не падал в рамках тестов | PASS |
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
```
|
||||
tests/ -q --tb=short:
|
||||
........................................................................ [100%]
|
||||
941 passed, 1 warning in 25.37s
|
||||
|
||||
tests/test_orch073_*.py -v:
|
||||
24 passed, 1 warning in 0.54s
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-073, не блокирует.)
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс зелёный (941 passed), все 24 теста ORCH-073 PASS, smoke API OK,
|
||||
маркеры AC-1 присутствуют в `origin/main`, прод-контейнер не затронут. Задача готова к
|
||||
переходу на стадию `deploy-staging` (где будет проверен AC-10 — воспроизведение «исправлено
|
||||
навсегда» на двух задачах с правкой CHANGELOG).
|
||||
12
docs/work-items/ORCH-073/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-073/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-073
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
36
docs/work-items/ORCH-073/15-staging-log.md
Normal file
36
docs/work-items/ORCH-073/15-staging-log.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T13:29:31Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. All REAL pipeline checks passed (8/10).
|
||||
|
||||
Run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001):
|
||||
|
||||
```
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
Exit code: **0** → advance.
|
||||
|
||||
## Results
|
||||
|
||||
- **Block A (SMOKE)**: A1 /health, A2 /queue, A3 ORCH_STAGING=true — all PASS
|
||||
- **Block B (ACCESS)**: B4 Plane sandbox, B5 Gitea sandbox (push=true), B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS
|
||||
- **Block C (E2E, stub)**: C7 create issue, C8 trigger pipeline — PASS; C9a/C9b — waived sandbox-infra
|
||||
|
||||
REAL failed: none.
|
||||
|
||||
## Infra waiver (ORCH-061)
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
The two waived checks (C9a branch, C9b analyst-job) depend on SANDBOX bot accounts being members of the sandbox Plane project — infra-only, not pipeline regression. Tolerated under `staging_infra_tolerance_enabled=true` since every REAL check is green. Exit code remains the source of truth (fail-closed: any REAL failure still yields exit 1).
|
||||
@@ -1,3 +1,4 @@
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -395,6 +396,50 @@ class Settings(BaseSettings):
|
||||
merge_pr_timeout_s: int = 60
|
||||
merge_verify_timeout_s: int = 60
|
||||
|
||||
# ORCH-026: intra-repo merge serialisation (Level A) + declarative task
|
||||
# dependencies (Level B). Level A reuses the ORCH-043/065 merge-lease window
|
||||
# (no new mechanism) — the merge-lease already serialises "merge -> main-updated"
|
||||
# per repo; the ONLY new behaviour is an unconditional pre-merge rebase. Level B
|
||||
# adds a new ADDITIVE job_deps table + a NOT EXISTS gate in claim_next_job. Both
|
||||
# features are inert without data (no applicable repo / no declared deps) ->
|
||||
# zero regression for enduro-trails.
|
||||
# premerge_rebase_always -> Level A (A-2): when True, check_branch_mergeable
|
||||
# ALWAYS rebases the task branch onto the CURRENT
|
||||
# origin/main UNDER the merge-lease (not only when
|
||||
# branch_is_behind_main) — a deterministic anti-phantom
|
||||
# that does not depend on the ancestor check's precision.
|
||||
# auto_rebase_onto_main is a cheap no-op on an already
|
||||
# up-to-date branch (rc 0, push up-to-date, CI not
|
||||
# retriggered). Scope = merge_gate_repos (empty ->
|
||||
# self-hosting). Kill-switch (False -> exactly the
|
||||
# ORCH-043 behaviour: rebase only when behind). Env
|
||||
# ORCH_PREMERGE_REBASE_ALWAYS.
|
||||
# task_deps_enabled -> Level B (B-2): global kill-switch for the scheduler
|
||||
# dependency gate. False -> claim_next_job is 1:1 as
|
||||
# ORCH-1 (the NOT EXISTS clause is omitted). Inert when
|
||||
# job_deps is empty. Env ORCH_TASK_DEPS_ENABLED.
|
||||
# task_deps_source -> declaration source: db|plane|hybrid (default db).
|
||||
# The scheduler ALWAYS reads the DB cache (offline-safe
|
||||
# hot path); plane/hybrid additionally ingest Plane
|
||||
# `blocked-by` relations into job_deps at task creation.
|
||||
# Env ORCH_TASK_DEPS_SOURCE.
|
||||
premerge_rebase_always: bool = True
|
||||
task_deps_enabled: bool = True
|
||||
task_deps_source: str = "db"
|
||||
|
||||
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify
|
||||
# under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a
|
||||
# secondary deterministic (no-LLM) guard checks that a declarative set of markers
|
||||
# for recently-merged tasks (MAIN_REGRESSION_MARKERS in merge_gate.py) is still
|
||||
# present in origin/main — i.e. a CHANGELOG-rebase or phantom-merge did not silently
|
||||
# roll back a neighbouring task's code. A missing marker (deterministic count==0) ->
|
||||
# ALERT + HOLD (task stays on `deploy`, NOT done); an infra/git error on the grep
|
||||
# itself -> fail-OPEN (do not block done; SHA-in-main remains the primary gate).
|
||||
# regression_guard_enabled -> kill-switch (env ORCH_REGRESSION_GUARD_ENABLED);
|
||||
# reuses the merge_verify_applies scope (self-hosting /
|
||||
# merge_verify_repos), so non-self repos are a no-op.
|
||||
regression_guard_enabled: bool = True
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
@@ -422,6 +467,24 @@ class Settings(BaseSettings):
|
||||
tracker_live_status_ttl_s: int = 60
|
||||
tracker_live_status_timeout_s: int = 3
|
||||
|
||||
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char
|
||||
# cap was a hygiene limit, not structural (slug is cut to [:30] independently,
|
||||
# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default
|
||||
# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash).
|
||||
qg0_title_max: int = 200
|
||||
|
||||
@field_validator("qg0_title_max", mode="before")
|
||||
@classmethod
|
||||
def _qg0_title_max_default(cls, v):
|
||||
# Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, the
|
||||
# process must not crash on startup. Never raises (self-hosting safety).
|
||||
try:
|
||||
if v is None or (isinstance(v, str) and v.strip() == ""):
|
||||
return 200
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 200
|
||||
|
||||
class Config:
|
||||
env_prefix = "ORCH_"
|
||||
env_file = ".env"
|
||||
|
||||
130
src/db.py
130
src/db.py
@@ -123,6 +123,24 @@ def init_db():
|
||||
# tracker can show "твоё время" without recomputing from activity history.
|
||||
_ensure_column(conn, "tasks", "brd_review_started_at", "TEXT")
|
||||
_ensure_column(conn, "tasks", "brd_review_ended_at", "TEXT")
|
||||
# ORCH-026 (Level B): declarative task dependencies. job_deps stores the
|
||||
# directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The
|
||||
# scheduler gate in claim_next_job keeps B queued until every A reaches
|
||||
# tasks.stage='done'. Purely ADDITIVE (CREATE TABLE/INDEX IF NOT EXISTS, no
|
||||
# change to jobs/tasks/agent_runs/events columns) -> idempotent and safe on
|
||||
# the live shared prod DB (enduro-trails data untouched). The logical FK on
|
||||
# tasks.id is intentional (no REFERENCES, mirrors jobs.task_id) so the
|
||||
# migration cannot fail on a pre-existing DB. See 08-data-requirements.md.
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS job_deps (
|
||||
task_id INTEGER NOT NULL,
|
||||
depends_on_task_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (task_id, depends_on_task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -466,12 +484,28 @@ def claim_next_job() -> dict | None:
|
||||
so the SELECT+UPDATE pair is consistent. Returns the claimed job dict or None
|
||||
when the queue is empty.
|
||||
"""
|
||||
# ORCH-026 (Level B, B-2): scheduler dependency gate. When task_deps_enabled
|
||||
# is on, a job whose task has an UNFINISHED declared dependency
|
||||
# (job_deps.depends_on_task_id -> a task with stage != 'done') is NOT
|
||||
# claimable -> it stays 'queued' without occupying a max_concurrency slot.
|
||||
# Jobs with a NULL task_id (no task) or with no job_deps rows are unaffected
|
||||
# (NOT EXISTS is True). Kill-switch off -> the clause is omitted -> 1:1 the
|
||||
# ORCH-1 query. The gate reads only the DB (offline-safe hot path).
|
||||
dep_gate = ""
|
||||
if getattr(settings, "task_deps_enabled", False):
|
||||
dep_gate = (
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
|
||||
" WHERE d.task_id = jobs.task_id AND t.stage != 'done'"
|
||||
") "
|
||||
)
|
||||
conn = get_db()
|
||||
try:
|
||||
while True:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM jobs WHERE status='queued' "
|
||||
"AND (available_at IS NULL OR available_at <= datetime('now')) "
|
||||
f"{dep_gate}"
|
||||
"ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
@@ -705,6 +739,102 @@ def recent_jobs(limit: int = 10) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-026 (Level B): declarative task-dependency helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_dependency(task_id: int, depends_on_task_id: int) -> bool:
|
||||
"""Declare that task ``task_id`` (B) is blocked-by ``depends_on_task_id`` (A).
|
||||
|
||||
Idempotent INSERT OR IGNORE against the job_deps PK (re-declaring the same
|
||||
edge is a no-op). A self-edge (task depends on itself) is rejected — it would
|
||||
deadlock the task forever and can never be satisfied. never-raise
|
||||
(self-hosting safety, AC-G1): any DB error -> returns False, the caller must
|
||||
not crash the webhook / worker. Returns True iff a NEW edge row was inserted.
|
||||
"""
|
||||
if task_id is None or depends_on_task_id is None:
|
||||
return False
|
||||
if task_id == depends_on_task_id:
|
||||
return False
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) "
|
||||
"VALUES (?, ?)",
|
||||
(task_id, depends_on_task_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_dependencies(task_id: int) -> list[int]:
|
||||
"""Return the list of depends_on_task_id (A) that ``task_id`` (B) waits for.
|
||||
|
||||
never-raise: any DB error -> [] (conservative: caller treats the task as
|
||||
having no declared dependency rather than crashing).
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT depends_on_task_id FROM job_deps WHERE task_id = ?",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_dependency_edges() -> list[tuple[int, int]]:
|
||||
"""Return ALL declared edges as ``(task_id, depends_on_task_id)`` tuples.
|
||||
|
||||
Used by the cycle detector (DFS over the whole declared graph) and the
|
||||
/queue snapshot. never-raise -> [] on any DB error.
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT task_id, depends_on_task_id FROM job_deps"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [(r[0], r[1]) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_unfinished_dependencies(task_id: int) -> list[dict]:
|
||||
"""Return the UNFINISHED dependencies of ``task_id`` (A's not yet 'done').
|
||||
|
||||
Each dict carries the predecessor's ``id``, ``work_item_id`` and ``stage``
|
||||
so the readiness gate / Telegram waiting-line can name what B is waiting for.
|
||||
never-raise -> [] on any DB error (treated as "ready", consistent with the
|
||||
scheduler omitting the gate on failure).
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id AS id, t.work_item_id AS work_item_id, t.stage AS stage "
|
||||
"FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
|
||||
"WHERE d.task_id = ? AND t.stage != 'done'",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-1b (resilience): transient backoff helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -148,6 +148,7 @@ async def queue():
|
||||
from .job_reaper import reaper
|
||||
from . import post_deploy
|
||||
from . import merge_gate
|
||||
from . import task_deps
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
@@ -157,5 +158,8 @@ async def queue():
|
||||
"reaper": reaper.status(),
|
||||
"post_deploy": post_deploy.status(),
|
||||
"merge_verify": merge_gate.merge_verify_status(),
|
||||
# ORCH-026 (G-2): declarative task-dependency observability (read-only,
|
||||
# NOT a source of truth) — declared edges, blocked tasks, detected cycle.
|
||||
"task_deps": task_deps.snapshot(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
@@ -445,25 +445,30 @@ def reclaim_stale_lease(repo: str) -> bool:
|
||||
# ORCH-065: idempotent merge finalization guard (Problem C)
|
||||
# ---------------------------------------------------------------------------
|
||||
def pr_already_merged(repo: str, branch: str) -> bool:
|
||||
"""Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2).
|
||||
"""Return True iff the **code-PR of ``branch``** is ALREADY merged (idempotency-guard).
|
||||
|
||||
A deterministic, read-only guard the merge path consults BEFORE attempting a
|
||||
(second) merge so a re-driven / reaped task is idempotent: an already-merged
|
||||
PR -> no-op, never a duplicate merge and never an error. This is the ONLY new
|
||||
merge-related helper and it does NOT merge — it only READS the PR state via
|
||||
the existing Gitea client, so it does not introduce duplicate merge logic.
|
||||
ORCH-073 ADR-001 Р-2 (FR-2): this is an **idempotency-guard for ``merge_pr``**, NOT
|
||||
a source of truth for ``done`` (the only proof of merge is SHA-in-main, FR-1 /
|
||||
``verify_merged_to_main``). It lets a re-driven / reaped ``merge_pr`` be idempotent:
|
||||
the code-PR is already merged -> no-op, never a duplicate merge.
|
||||
|
||||
Consultation point: the actual merge actor is the **deployer agent** (it merges
|
||||
the feature PR at the start of the ``deploy`` stage — see webhooks/gitea.py),
|
||||
so the wiring lives in the deployer prompt (``.openclaw/agents/deployer.md``),
|
||||
which runs this exact function before any (re-)merge. The merge-gate quality
|
||||
check (``qg.checks.check_branch_mergeable``) is intentionally NOT modified
|
||||
(ORCH-065 AC-13: ``check_*`` behaviour unchanged) — it runs on the FIRST
|
||||
deploy-staging -> deploy edge and does not re-run on a ``deploy``-stage re-drive,
|
||||
which is exactly where the second-merge risk lives.
|
||||
Root-cause fix (G4 audit): the previous implementation returned True for ANY
|
||||
``merged == True`` PR returned by ``GET /pulls?state=all&head=<branch>``. Gitea's
|
||||
``head`` query-param filters unreliably for a bare branch name, so auto docs-PRs
|
||||
(staging/deploy logs, ``head=docs/*``) leaked into the result and were counted as
|
||||
"merged" — the ORCH-067/069 phantom-merge. We now apply an EXPLICIT in-loop filter
|
||||
instead of trusting the query-param: a PR counts only when it carries the code of
|
||||
THIS feature-branch into ``main``:
|
||||
|
||||
* ``pr.merged is True`` AND
|
||||
* ``pr.head.ref == branch`` (the code of exactly this feature-branch) AND
|
||||
* ``pr.base.ref == "main"`` (target is main, not a docs/other base).
|
||||
|
||||
This excludes auto docs-PRs (different ``head.ref``) and PRs onto a non-``main``
|
||||
base, so a merged docs-PR can no longer make ``merge_pr`` skip a real code merge.
|
||||
|
||||
Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=<branch>`` and
|
||||
reports True when any matching PR has ``merged == True``. Never raises (AC-9):
|
||||
reports True only when a matching PR passes the filter above. Never raises (AC-9):
|
||||
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
|
||||
normal gate re-evaluate rather than silently skipping a real merge).
|
||||
"""
|
||||
@@ -479,7 +484,11 @@ def pr_already_merged(repo: str, branch: str) -> bool:
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
for pr in resp.json() or []:
|
||||
if pr.get("merged") is True:
|
||||
if (
|
||||
pr.get("merged") is True
|
||||
and pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
@@ -505,6 +514,7 @@ def pr_already_merged(repo: str, branch: str) -> bool:
|
||||
_MERGE_VERIFY_COUNTERS: dict = {
|
||||
"merge_verified_total": 0,
|
||||
"not_merged_alerts_total": 0,
|
||||
"main_regressed_alerts_total": 0, # ORCH-073 Р-4: regression-guard HOLD+alert count.
|
||||
"last_alert_wi": None,
|
||||
}
|
||||
|
||||
@@ -526,6 +536,15 @@ def note_not_merged_alert(work_item_id: str | None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def note_main_regressed_alert(work_item_id: str | None) -> None:
|
||||
"""Bump the 'main regressed (marker missing)' counter (ORCH-073 Р-4). Never raises."""
|
||||
try:
|
||||
_MERGE_VERIFY_COUNTERS["main_regressed_alerts_total"] += 1
|
||||
_MERGE_VERIFY_COUNTERS["last_alert_wi"] = work_item_id
|
||||
except Exception: # noqa: BLE001 - observability must never break a decision
|
||||
pass
|
||||
|
||||
|
||||
def merge_verify_status() -> dict:
|
||||
"""Snapshot of the merge-verify under-gate for GET /queue. Never raises."""
|
||||
try:
|
||||
@@ -534,6 +553,7 @@ def merge_verify_status() -> dict:
|
||||
"repos": settings.merge_verify_repos or "",
|
||||
"merge_verified_total": _MERGE_VERIFY_COUNTERS["merge_verified_total"],
|
||||
"not_merged_alerts_total": _MERGE_VERIFY_COUNTERS["not_merged_alerts_total"],
|
||||
"main_regressed_alerts_total": _MERGE_VERIFY_COUNTERS["main_regressed_alerts_total"],
|
||||
"last_alert_wi": _MERGE_VERIFY_COUNTERS["last_alert_wi"],
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
@@ -578,7 +598,10 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
Algorithm:
|
||||
1. ``pr_already_merged`` -> True -> no-op ``(True, "already-merged")`` (INV-5/AC-9).
|
||||
2. ``GET /repos/{owner}/{repo}/pulls?state=open`` -> the open PR whose head ref
|
||||
== ``branch`` -> its index. No open PR -> ``(False, "no open PR")``.
|
||||
== ``branch`` AND base ref == ``main`` -> its index. ORCH-073 ADR-001 Р-3
|
||||
(FR-3) adds the ``base == main`` filter so the actor merges exactly the
|
||||
feature code-PR and never an auto docs-PR / a PR onto a foreign base. No
|
||||
such open PR -> ``(False, "no open PR")``.
|
||||
3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) ->
|
||||
200/201 -> ``(True, "merged PR #<n>")``; otherwise ``(False, "<reason>")``.
|
||||
|
||||
@@ -602,7 +625,10 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
return False, f"list PRs failed: HTTP {resp.status_code}"
|
||||
index = None
|
||||
for pr in resp.json() or []:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
if (
|
||||
pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
index = pr.get("number")
|
||||
break
|
||||
if index is None:
|
||||
@@ -631,26 +657,32 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
def verify_merged_to_main(repo: str, branch: str, sha: str) -> bool:
|
||||
"""Return True iff the deployed commit is confirmed merged into ``origin/main``.
|
||||
|
||||
Post-deploy verification (FR-2 / D4): the merge is confirmed when EITHER
|
||||
* ``pr_already_merged(repo, branch)`` is True (Gitea ``PR.merged == true``), OR
|
||||
* ``git merge-base --is-ancestor <sha> origin/main`` succeeds in the per-branch
|
||||
worktree (after ``git fetch origin main``), i.e. the validated SHA is an
|
||||
ancestor of the current ``origin/main``.
|
||||
Post-deploy verification — ORCH-073 ADR-001 Р-1 (FR-1): the merge is confirmed by
|
||||
the SINGLE, authoritative fact "the deployed commit IS an ancestor of the current
|
||||
``origin/main``":
|
||||
|
||||
* after ``git fetch origin main`` (in the per-branch worktree),
|
||||
``git merge-base --is-ancestor <sha> origin/main`` returns ``rc == 0``.
|
||||
|
||||
The former OR-branch ``pr_already_merged(repo, branch)`` was REMOVED: a merged
|
||||
``PR.merged == true`` is no longer sufficient to confirm a merge. That branch was
|
||||
the ORCH-067/069 phantom-merge root cause — an auto docs-PR (staging/deploy logs)
|
||||
counted as "merged" via the unreliable Gitea ``head`` query, turning merge-verify
|
||||
falsely GREEN while the code-PR was never merged. ``pr_already_merged`` now serves
|
||||
ONLY as an idempotency-guard inside ``merge_pr`` (Р-2/Р-3), never as proof of merge.
|
||||
|
||||
``sha`` is the validated commit (``image_freshness.validated_revision`` =
|
||||
worktree ``git rev-parse HEAD``). An empty ``sha`` makes the git branch
|
||||
inconclusive (only the PR-merged branch can then confirm).
|
||||
worktree ``git rev-parse HEAD``). An empty ``sha`` is inconclusive -> ``False``
|
||||
(fail-closed: alert + HOLD), since the SHA-in-main check cannot run without it.
|
||||
|
||||
Never-raise (INV-1/AC-7 / TC-04): any git/HTTP error -> ``False`` (= "not
|
||||
confirmed" -> fail-closed for ``done``: alert + HOLD). The exception is NEVER
|
||||
propagated into ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
if pr_already_merged(repo, branch):
|
||||
return True
|
||||
if not sha:
|
||||
logger.warning(
|
||||
"verify_merged_to_main: empty SHA for %s/%s and PR not known-merged",
|
||||
"verify_merged_to_main: empty SHA for %s/%s -> cannot confirm SHA-in-main",
|
||||
repo, branch,
|
||||
)
|
||||
return False
|
||||
@@ -675,3 +707,110 @@ def verify_merged_to_main(repo: str, branch: str, sha: str) -> bool:
|
||||
"verify_merged_to_main unexpected error for %s/%s: %s", repo, branch, e
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard.
|
||||
#
|
||||
# A secondary, deterministic (no-LLM) guard that runs in `_handle_merge_verify`
|
||||
# AFTER the SHA-in-main check (verify_merged_to_main, FR-1) confirms the deployed
|
||||
# commit, and BEFORE the task is stamped `done`. It checks that a DECLARATIVE set
|
||||
# of markers for recently-merged tasks is still present in `origin/main` — i.e. a
|
||||
# CHANGELOG-rebase / phantom-merge did not silently roll back a neighbouring task's
|
||||
# code (the ORCH-067/069 failure mode, which SHA-in-main alone would not catch when
|
||||
# the deployed SHA itself IS in main but a sibling's code is gone).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Declarative, append-only marker set (ADR-001 Р-4). Each future task that lands
|
||||
# significant code SHOULD append its own (task, marker_substring, path) row so the
|
||||
# guard protects it from a later phantom-merge / rebase rollback. Kept in code (not
|
||||
# DB / Plane — a non-goal) so it versions together with the fix it protects.
|
||||
MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
|
||||
("ORCH-067", "plane_issue_link", "src/notifications.py"),
|
||||
("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"),
|
||||
]
|
||||
|
||||
|
||||
def check_main_regression(repo: str, branch: str) -> tuple[bool, str]:
|
||||
"""Verify the declarative marker set is still present in ``origin/main``.
|
||||
|
||||
ORCH-073 ADR-001 Р-4 (FR-5). For each ``(task, marker, path)`` in
|
||||
``MAIN_REGRESSION_MARKERS`` run ``git grep -c <marker> origin/main -- <path>`` in
|
||||
the per-branch worktree (after ``git fetch origin main``). A DETERMINISTIC count
|
||||
of ``0`` for any marker means a neighbouring task's code was rolled back ->
|
||||
regression.
|
||||
|
||||
Returns ``(ok, reason)``:
|
||||
* ``(True, "markers intact (<n>)")`` — every marker present -> proceed.
|
||||
* ``(False, "main regressed: <task> ...")`` — a marker is deterministically
|
||||
absent (count==0) -> caller HOLDs the task (NOT done) + alerts.
|
||||
|
||||
**Fail-OPEN on infra error** (intentional trade-off, ADR-001 Р-4): any git/OS
|
||||
error on the grep itself -> ``(True, "guard inconclusive: <reason>")`` so a flaky
|
||||
git never produces a false HOLD. "Regressed" is asserted ONLY on a deterministic
|
||||
``count == 0``, never on "could not determine". The PRIMARY fail-closed gate is
|
||||
SHA-in-main (FR-1); this marker-grep is a secondary, best-effort guard.
|
||||
|
||||
Never raises (INV-1): any unexpected error -> ``(True, "guard error: ...")``.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-open
|
||||
logger.warning(
|
||||
"check_main_regression: worktree error for %s/%s: %s (fail-open)",
|
||||
repo, branch, e,
|
||||
)
|
||||
return True, f"guard inconclusive: worktree error: {e}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", wt, "fetch", "origin", "main"],
|
||||
capture_output=True, timeout=settings.merge_verify_timeout_s,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(
|
||||
"check_main_regression: fetch error for %s/%s: %s (fail-open)",
|
||||
repo, branch, e,
|
||||
)
|
||||
return True, f"guard inconclusive: fetch error: {e}"
|
||||
|
||||
for task, marker, path in MAIN_REGRESSION_MARKERS:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "-C", wt, "grep", "-c", marker, "origin/main", "--", path],
|
||||
capture_output=True, text=True, timeout=_SHORT_TIMEOUT,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
# Infra error on this marker -> fail-open (do NOT assert regression).
|
||||
logger.warning(
|
||||
"check_main_regression: grep error for %s (%s @ %s): %s (fail-open)",
|
||||
task, marker, path, e,
|
||||
)
|
||||
return True, f"guard inconclusive: grep error for {task}: {e}"
|
||||
# git grep exit codes: 0 = match(es) found, 1 = no match, >1 = real error.
|
||||
if r.returncode == 0:
|
||||
continue
|
||||
if r.returncode == 1:
|
||||
# Deterministic absence -> regression of a neighbouring task's code.
|
||||
logger.warning(
|
||||
"check_main_regression: marker MISSING in origin/main for %s "
|
||||
"(%s @ %s) -> main regressed", task, marker, path,
|
||||
)
|
||||
return False, f"main regressed: {task} code missing ({marker} @ {path})"
|
||||
# rc > 1 -> git error (e.g. bad path/ref) -> inconclusive -> fail-open.
|
||||
logger.warning(
|
||||
"check_main_regression: ambiguous git grep rc=%s for %s (%s @ %s) "
|
||||
"(fail-open)", r.returncode, task, marker, path,
|
||||
)
|
||||
return True, f"guard inconclusive: git grep rc={r.returncode} for {task}"
|
||||
|
||||
return True, f"markers intact ({len(MAIN_REGRESSION_MARKERS)})"
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-open
|
||||
logger.warning(
|
||||
"check_main_regression unexpected error for %s/%s: %s (fail-open)",
|
||||
repo, branch, e,
|
||||
)
|
||||
return True, f"guard error: {e}"
|
||||
|
||||
@@ -380,6 +380,22 @@ def render_task_tracker(task_id: int) -> str:
|
||||
status_line = f"\U0001f4cd {status_label}"
|
||||
lines = [header, status_line, bar]
|
||||
|
||||
# ORCH-026 (B-4): waiting-line for a task blocked by an unfinished declared
|
||||
# dependency. Shows WHAT the task is waiting on ("⏳ ждёт ORCH-NNN"),
|
||||
# so the single tracker card (invariant preserved) makes the wait visible.
|
||||
# Never breaks the render: any error -> no waiting-line.
|
||||
if not done:
|
||||
try:
|
||||
from . import task_deps
|
||||
from .config import settings as _settings
|
||||
if getattr(_settings, "task_deps_enabled", False):
|
||||
ready, waiting_on = task_deps.is_task_ready(task_id)
|
||||
if not ready and waiting_on:
|
||||
waits = ", ".join(link_for(w) for w in waiting_on)
|
||||
lines.append(f"⏳ ждёт {waits}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _stage_line(label, run):
|
||||
usage = {
|
||||
"input_tokens": run["input_tokens"],
|
||||
|
||||
@@ -433,6 +433,72 @@ def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> str
|
||||
return None
|
||||
|
||||
|
||||
def fetch_blocked_by_issue_ids(issue_id: str, project_id: str, timeout: int = 10) -> list[str]:
|
||||
"""ORCH-026 (B-1): list the Plane issue UUIDs that ``issue_id`` is BLOCKED-BY.
|
||||
|
||||
Reads the Plane issue-relation endpoint and returns the related issue UUIDs
|
||||
declared as ``blocked_by`` (i.e. the predecessors A that this task B waits
|
||||
for). Plane's relation payload shape has varied across versions, so the parse
|
||||
is defensive: it accepts either a grouped object (``{"blocked_by": [...]}``)
|
||||
or a flat list of ``{"relation_type": ..., "related_issue": ...}`` rows, and
|
||||
pulls a uuid from ``related_issue`` / ``issue`` / ``id`` (bare uuid or nested
|
||||
``{"id": ...}``).
|
||||
|
||||
never-raise (AC-G1, self-hosting): a Plane outage / non-2xx / unexpected
|
||||
shape -> ``[]`` (no edge declared), so the ingestion degrades conservatively
|
||||
and the pipeline never stalls on the network.
|
||||
"""
|
||||
if not issue_id or not project_id:
|
||||
return []
|
||||
url = (
|
||||
f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}"
|
||||
f"/issues/{issue_id}/issue-relation/"
|
||||
)
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_blocked_by_issue_ids failed for {issue_id}: {e}")
|
||||
return []
|
||||
|
||||
def _uuid_of(row) -> str | None:
|
||||
if isinstance(row, str):
|
||||
return row
|
||||
if isinstance(row, dict):
|
||||
for key in ("related_issue", "issue", "id"):
|
||||
v = row.get(key)
|
||||
if isinstance(v, dict):
|
||||
v = v.get("id")
|
||||
if v:
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
out: list[str] = []
|
||||
try:
|
||||
rows = []
|
||||
if isinstance(body, dict):
|
||||
# Grouped shape: {"blocked_by": [...], "blocking": [...], ...}
|
||||
if "blocked_by" in body and isinstance(body["blocked_by"], list):
|
||||
rows = body["blocked_by"]
|
||||
else:
|
||||
# Flat shape nested under common envelope keys.
|
||||
rows = body.get("results") or body.get("relations") or []
|
||||
elif isinstance(body, list):
|
||||
rows = body
|
||||
for row in rows:
|
||||
# In the flat shape, keep only blocked_by rows.
|
||||
if isinstance(row, dict) and row.get("relation_type") not in (None, "blocked_by"):
|
||||
continue
|
||||
uid = _uuid_of(row)
|
||||
if uid and uid != issue_id:
|
||||
out.append(uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_blocked_by_issue_ids parse error for {issue_id}: {e}")
|
||||
return []
|
||||
return out
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
|
||||
@@ -673,8 +673,19 @@ def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[b
|
||||
return False, reason
|
||||
|
||||
try:
|
||||
# ORCH-026 (Level A, A-2): proactive pre-merge rebase. When
|
||||
# premerge_rebase_always is on, ALWAYS rebase onto the CURRENT
|
||||
# origin/main under the held lease — even when branch_is_behind_main
|
||||
# says "not behind". The ancestor check can miss a divergence
|
||||
# (squash/force-push history, ORCH-073 phantom-merge class), so an
|
||||
# unconditional rebase is a deterministic anti-phantom: it guarantees
|
||||
# B carries A's code before merge. auto_rebase_onto_main is a cheap
|
||||
# no-op on an already up-to-date branch (rc 0, push up-to-date, CI not
|
||||
# retriggered). Kill-switch off -> 1:1 the ORCH-043 short-circuit
|
||||
# below (rebase only when behind).
|
||||
always = bool(getattr(settings, "premerge_rebase_always", False))
|
||||
# Double-check under the lease: another task may have just merged.
|
||||
if not merge_gate.branch_is_behind_main(repo, branch):
|
||||
if not always and not merge_gate.branch_is_behind_main(repo, branch):
|
||||
logger.info("check_branch_mergeable: %s up-to-date with main", branch)
|
||||
return True, "branch up-to-date with main"
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from .plane_sync import (
|
||||
from .webhooks.plane import handle_status_start, handle_verdict
|
||||
from .notifications import send_telegram, link_for
|
||||
from . import projects
|
||||
from . import task_deps
|
||||
|
||||
logger = logging.getLogger("orchestrator.reconciler")
|
||||
|
||||
@@ -165,6 +166,16 @@ class Reconciler:
|
||||
f"reconciler F-1: task {task.get('id')} "
|
||||
f"(stage={task.get('stage')}) failed: {e}"
|
||||
)
|
||||
# ORCH-026 (B-3) backstop: surface ANY dependency deadlock in the declared
|
||||
# graph, even one whose tasks are not individually evaluated above (e.g. no
|
||||
# active queued job). One alert per cycle; never-raise.
|
||||
if settings.task_deps_enabled:
|
||||
try:
|
||||
cyc = task_deps.find_any_cycle()
|
||||
if cyc:
|
||||
task_deps.handle_cycle(cyc)
|
||||
except Exception as e: # noqa: BLE001 - never break the sweep
|
||||
logger.error(f"reconciler F-1: cycle backstop failed: {e}")
|
||||
|
||||
def _reconcile_gate_task(self, task: dict) -> None:
|
||||
task_id = task["id"]
|
||||
@@ -194,6 +205,18 @@ class Reconciler:
|
||||
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
|
||||
if self._is_blocked_or_needs_input(task):
|
||||
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
|
||||
# past its depends-on (mirrors the Blocked/Needs-Input skip). Local DB,
|
||||
# never-raise (is_task_ready fails OPEN). If the wait is actually a
|
||||
# dependency DEADLOCK (cycle), surface it (Blocked + alert) once.
|
||||
if settings.task_deps_enabled:
|
||||
ready, _waiting = task_deps.is_task_ready(task_id)
|
||||
if not ready:
|
||||
cyc = task_deps.detect_cycle(task_id)
|
||||
if cyc:
|
||||
task_deps.handle_cycle(cyc)
|
||||
return
|
||||
result = advance_if_gate_passed(
|
||||
task_id,
|
||||
stage,
|
||||
|
||||
@@ -1277,6 +1277,50 @@ def _deploy_finalize_defer_count(task_id: int) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def _hold_main_regressed(
|
||||
task_id, repo, work_item_id, branch, guard_msg: str, result: AdvanceResult
|
||||
) -> bool:
|
||||
"""HOLD the task because the regression guard found neighbouring code missing.
|
||||
|
||||
ORCH-073 Р-4 (FR-5 / AC-5): the deployed SHA IS in `main` (FR-1 passed) but a
|
||||
declarative marker of a recently-merged task is gone -> a phantom-merge / rebase
|
||||
rolled back sibling code. Reaction is ALERT-only + HOLD (Telegram + Plane
|
||||
``set_issue_blocked`` + comment), task stays on `deploy` (NOT done), NO rollback
|
||||
to development (an infra defect, not a code fault — symmetric to the not-merged
|
||||
HOLD). Returns ``True`` (INTERVENED). Never breaks the HOLD on a notify error.
|
||||
"""
|
||||
merge_gate.note_main_regressed_alert(work_item_id)
|
||||
msg = (
|
||||
f"main regressed: {guard_msg} (repo={repo}, branch={branch}, "
|
||||
f"wi={work_item_id}). Соседний код пропал из `main` — задача удержана на "
|
||||
f"`deploy` (НЕ done). Нужно ручное восстановление кода."
|
||||
)
|
||||
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 Регресс `main`: " + guard_msg + ". Код соседней задачи "
|
||||
"пропал из `main`. Задача удержана на `deploy` (НЕ done) — нужно "
|
||||
"восстановить код и повторить approve.",
|
||||
author="deployer",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: plane regressed 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}: main-regressed telegram failed: {e}")
|
||||
result.alerted = True
|
||||
result.note = "main-regressed-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.
|
||||
|
||||
@@ -1317,6 +1361,20 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
|
||||
|
||||
confirmed = merge_gate.verify_merged_to_main(repo, branch, sha)
|
||||
if confirmed:
|
||||
# ORCH-073 Р-4 (FR-5): secondary main-integrity regression guard. The
|
||||
# deployed SHA is in main (FR-1), but a CHANGELOG-rebase / phantom-merge
|
||||
# could still have rolled back a NEIGHBOURING task's code. Verify the
|
||||
# declarative marker set is intact; a deterministic miss -> HOLD + alert
|
||||
# (NOT done, no rollback — an infra defect, not a code fault). Fail-OPEN
|
||||
# on a git error of the guard itself (SHA-in-main remains the primary
|
||||
# gate). Honours the same scope/kill-switch as the under-gate.
|
||||
if settings.regression_guard_enabled:
|
||||
guard_ok, guard_msg = merge_gate.check_main_regression(repo, branch)
|
||||
if not guard_ok:
|
||||
return _hold_main_regressed(
|
||||
task_id, repo, work_item_id, branch, guard_msg, result
|
||||
)
|
||||
|
||||
merge_gate.note_merge_verified()
|
||||
try:
|
||||
self_deploy.record_merged_to_main(repo, work_item_id, branch, True)
|
||||
|
||||
335
src/task_deps.py
Normal file
335
src/task_deps.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""ORCH-026 (Level B): declarative task-dependency logic.
|
||||
|
||||
Leaf module — pure, unit-testable functions over the additive ``job_deps`` table
|
||||
(see src/db.py / 08-data-requirements.md). It answers two questions the rest of
|
||||
the pipeline asks:
|
||||
|
||||
* "is task B ready to run?" — every declared predecessor A reached
|
||||
``tasks.stage = 'done'`` (``is_task_ready``). The scheduler gate in
|
||||
``db.claim_next_job`` enforces the same predicate in SQL; this Python copy is
|
||||
for the reconciler skip and for naming WHAT a task waits on (visibility).
|
||||
* "is there a dependency deadlock?" — a directed cycle A->B->A (or longer) can
|
||||
never be satisfied, so the tasks in it would wait forever. ``detect_cycle`` /
|
||||
``find_any_cycle`` find one deterministically; ``handle_cycle`` escalates it
|
||||
to Blocked + alert so the deadlock is visible instead of silent.
|
||||
|
||||
never-raise contract (AC-G1, self-hosting safety): EVERY public function
|
||||
degrades conservatively on any error (DB/import) and NEVER propagates an
|
||||
exception into the worker / reconciler / webhook. Readiness fails OPEN
|
||||
(``True``) so a transient DB error cannot wedge the whole queue; cycle detection
|
||||
fails CLOSED-safe (``None`` = "no cycle proven", do not block).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from . import db
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readiness gate (B-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_task_ready(task_id: int) -> tuple[bool, list[str]]:
|
||||
"""Return ``(ready, waiting_on)`` for a task.
|
||||
|
||||
``ready`` is True when the task has no declared dependency whose predecessor
|
||||
is still un-done (``tasks.stage != 'done'``). ``waiting_on`` is the list of
|
||||
predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still blocked
|
||||
by — used for the Telegram waiting-line / Plane visibility.
|
||||
|
||||
never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the
|
||||
scheduler omitting the gate when the DB read fails; a transient error must
|
||||
not wedge an otherwise-claimable task).
|
||||
"""
|
||||
if task_id is None:
|
||||
return True, []
|
||||
try:
|
||||
unfinished = db.get_unfinished_dependencies(task_id)
|
||||
except Exception:
|
||||
return True, []
|
||||
if not unfinished:
|
||||
return True, []
|
||||
waiting_on = [
|
||||
str(d.get("work_item_id") or d.get("id"))
|
||||
for d in unfinished
|
||||
]
|
||||
return False, waiting_on
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle / deadlock detection (B-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_adjacency(edges: list[tuple[int, int]]) -> dict[int, list[int]]:
|
||||
"""Build a ``task_id -> [depends_on_task_id, ...]`` adjacency map.
|
||||
|
||||
Edge direction follows the dependency: an edge (B, A) means "B depends on A",
|
||||
so we traverse from a dependent task towards its predecessors. A cycle in
|
||||
this graph is an unsatisfiable deadlock.
|
||||
"""
|
||||
adj: dict[int, list[int]] = {}
|
||||
for task_id, depends_on in edges:
|
||||
adj.setdefault(task_id, []).append(depends_on)
|
||||
return adj
|
||||
|
||||
|
||||
def _find_cycle_from(start: int, adj: dict[int, list[int]]) -> list[int] | None:
|
||||
"""Iterative DFS from ``start``; return a cycle path if one is reachable.
|
||||
|
||||
Returns the node sequence closing the cycle (e.g. ``[A, B, A]``) or None.
|
||||
Iterative (explicit stack) so a pathological deep graph cannot blow the
|
||||
Python recursion limit — relevant on the shared prod process.
|
||||
"""
|
||||
WHITE, GREY, BLACK = 0, 1, 2
|
||||
color: dict[int, int] = {}
|
||||
parent: dict[int, int] = {}
|
||||
# stack of (node, is_exit): is_exit=True marks the post-visit (color BLACK).
|
||||
stack: list[tuple[int, bool]] = [(start, False)]
|
||||
while stack:
|
||||
node, is_exit = stack.pop()
|
||||
if is_exit:
|
||||
color[node] = BLACK
|
||||
continue
|
||||
if color.get(node, WHITE) != WHITE:
|
||||
continue
|
||||
color[node] = GREY
|
||||
stack.append((node, True))
|
||||
for nxt in adj.get(node, []):
|
||||
c = color.get(nxt, WHITE)
|
||||
if c == GREY:
|
||||
# Back-edge -> cycle. Reconstruct path nxt..node via parent.
|
||||
path = [node]
|
||||
cur = node
|
||||
while cur != nxt and cur in parent:
|
||||
cur = parent[cur]
|
||||
path.append(cur)
|
||||
path.reverse()
|
||||
path.append(nxt)
|
||||
return path
|
||||
if c == WHITE:
|
||||
parent[nxt] = node
|
||||
stack.append((nxt, False))
|
||||
return None
|
||||
|
||||
|
||||
def detect_cycle(task_id: int, edges: list[tuple[int, int]] | None = None) -> list[int] | None:
|
||||
"""Detect a dependency cycle reachable from ``task_id``.
|
||||
|
||||
Returns the cycle path (node sequence, first == last) or None when the graph
|
||||
reachable from ``task_id`` is acyclic. ``edges`` may be injected (unit tests);
|
||||
otherwise the full declared edge set is read from the DB.
|
||||
|
||||
never-raise: any error -> None (do not falsely claim a deadlock on an error).
|
||||
"""
|
||||
if task_id is None:
|
||||
return None
|
||||
try:
|
||||
if edges is None:
|
||||
edges = db.get_dependency_edges()
|
||||
adj = _build_adjacency(edges)
|
||||
return _find_cycle_from(task_id, adj)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_any_cycle(edges: list[tuple[int, int]] | None = None) -> list[int] | None:
|
||||
"""Backstop: detect ANY cycle in the whole declared graph.
|
||||
|
||||
Used by the reconciler tick to surface a deadlock even when no specific task
|
||||
is being evaluated. Returns the first cycle found or None. never-raise -> None.
|
||||
"""
|
||||
try:
|
||||
if edges is None:
|
||||
edges = db.get_dependency_edges()
|
||||
adj = _build_adjacency(edges)
|
||||
for node in list(adj.keys()):
|
||||
cyc = _find_cycle_from(node, adj)
|
||||
if cyc:
|
||||
return cyc
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _work_item_id_for(task_id: int) -> str | None:
|
||||
"""Best-effort ``tasks.work_item_id`` lookup for a task_id (never-raise)."""
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT work_item_id FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return row[0] if row and row[0] else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def handle_cycle(cycle: list[int]) -> bool:
|
||||
"""Escalate a detected dependency cycle: Blocked + alert (B-3, AC-G1).
|
||||
|
||||
For every task in the cycle, sets its Plane issue to Blocked (best-effort)
|
||||
and sends ONE Telegram alert naming the cycle, so a deadlock is visible
|
||||
instead of a silent forever-wait. Does NOT mutate job_deps / stages — the
|
||||
declaration is the human's to fix. never-raise: any notify/Plane error is
|
||||
swallowed; the worker/reconciler never crashes. Returns True if an alert was
|
||||
attempted, False on a no-op / error.
|
||||
"""
|
||||
if not cycle:
|
||||
return False
|
||||
try:
|
||||
# Map task ids -> work-item ids for a human-readable chain.
|
||||
labels: list[str] = []
|
||||
seen: set[int] = set()
|
||||
for tid in cycle:
|
||||
wi = _work_item_id_for(tid)
|
||||
labels.append(wi or f"task#{tid}")
|
||||
if tid not in seen:
|
||||
seen.add(tid)
|
||||
chain = " -> ".join(labels)
|
||||
try:
|
||||
from . import notifications, plane_sync
|
||||
except Exception:
|
||||
return False
|
||||
# Blocked indication on each distinct issue in the cycle.
|
||||
for tid in seen:
|
||||
wi = _work_item_id_for(tid)
|
||||
if wi:
|
||||
try:
|
||||
plane_sync.set_issue_blocked(wi)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
notifications.send_telegram(
|
||||
f"\U0001f6a8 ORCH-026: dependency DEADLOCK detected (cycle): {chain}. "
|
||||
f"Tasks set to Blocked — fix the blocked-by declaration."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.error("ORCH-026: dependency cycle detected: %s", chain)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declaration (B-1) — db.add_dependency + immediate cycle escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def declare_dependency(task_id: int, depends_on_task_id: int) -> bool:
|
||||
"""Declare "task_id (B) blocked-by depends_on_task_id (A)" and check for a cycle.
|
||||
|
||||
Thin wrapper over ``db.add_dependency`` that, after a successful insert, runs
|
||||
``detect_cycle`` from the new dependent — so a freshly-introduced deadlock is
|
||||
surfaced (Blocked + alert) at declaration time (best UX, ADR B-3) rather than
|
||||
only by the reconciler backstop. The edge is NOT rolled back on a cycle (the
|
||||
SQL gate already keeps the cyclic tasks un-claimable; the human fixes the
|
||||
declaration) — we make it VISIBLE. never-raise: any error -> False.
|
||||
|
||||
Returns True iff a NEW edge row was inserted (idempotent re-declaration ->
|
||||
False, matching db.add_dependency).
|
||||
"""
|
||||
try:
|
||||
inserted = db.add_dependency(task_id, depends_on_task_id)
|
||||
# Always check for a cycle (even on a duplicate edge an existing cycle may
|
||||
# now be relevant), but only escalate when one is actually found.
|
||||
cyc = detect_cycle(task_id)
|
||||
if cyc:
|
||||
handle_cycle(cyc)
|
||||
return inserted
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ingest_plane_relations(
|
||||
task_id: int, issue_id: str, project_id: str
|
||||
) -> int:
|
||||
"""B-1 (plane/hybrid source): import Plane ``blocked-by`` relations into job_deps.
|
||||
|
||||
Reads the issue's ``blocked_by`` predecessors from Plane, resolves each to a
|
||||
local ``tasks.id`` (intra-repo only, v1) and declares the edge. A predecessor
|
||||
not yet known locally (no task row) is SKIPPED — the scheduler can only gate
|
||||
on tasks it knows; a re-run after that task is created will pick it up.
|
||||
|
||||
Active ONLY when ``task_deps_source`` is ``plane`` or ``hybrid`` (default
|
||||
``db`` -> no Plane call on the hot creation path). never-raise (self-hosting):
|
||||
any error -> 0 edges, the pipeline start is never blocked by Plane. Returns
|
||||
the number of edges declared.
|
||||
"""
|
||||
source = (getattr(settings, "task_deps_source", "db") or "db").strip().lower()
|
||||
if source not in ("plane", "hybrid"):
|
||||
return 0
|
||||
if not issue_id or not project_id:
|
||||
return 0
|
||||
try:
|
||||
from . import plane_sync
|
||||
blocked_by = plane_sync.fetch_blocked_by_issue_ids(issue_id, project_id)
|
||||
except Exception:
|
||||
return 0
|
||||
declared = 0
|
||||
for pred_issue in blocked_by:
|
||||
try:
|
||||
pred = db.get_task_by_plane_id(str(pred_issue))
|
||||
if not pred:
|
||||
continue
|
||||
if declare_dependency(task_id, pred["id"]):
|
||||
declared += 1
|
||||
except Exception:
|
||||
continue
|
||||
return declared
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability (/queue snapshot, G-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Read-only summary of the dependency subsystem for GET /queue (G-2).
|
||||
|
||||
Returns a dict (NOT a source of truth — pure observability):
|
||||
* ``enabled`` — task_deps_enabled flag;
|
||||
* ``source`` — task_deps_source (db|plane|hybrid);
|
||||
* ``edges`` — number of declared edges;
|
||||
* ``blocked_tasks`` — list of ``{task_id, work_item_id, waiting_on}`` for
|
||||
tasks with at least one un-done predecessor;
|
||||
* ``cycle`` — a detected cycle path (work-item labels) or None.
|
||||
|
||||
never-raise: any error -> a minimal dict with the flags and empty data.
|
||||
"""
|
||||
enabled = bool(getattr(settings, "task_deps_enabled", False))
|
||||
source = getattr(settings, "task_deps_source", "db")
|
||||
try:
|
||||
edges = db.get_dependency_edges()
|
||||
blocked: list[dict] = []
|
||||
for task_id in {e[0] for e in edges}:
|
||||
ready, waiting_on = is_task_ready(task_id)
|
||||
if not ready:
|
||||
blocked.append({
|
||||
"task_id": task_id,
|
||||
"work_item_id": _work_item_id_for(task_id),
|
||||
"waiting_on": waiting_on,
|
||||
})
|
||||
cyc = find_any_cycle(edges)
|
||||
cycle_labels = None
|
||||
if cyc:
|
||||
cycle_labels = [(_work_item_id_for(t) or f"task#{t}") for t in cyc]
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"source": source,
|
||||
"edges": len(edges),
|
||||
"blocked_tasks": blocked,
|
||||
"cycle": cycle_labels,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"source": source,
|
||||
"edges": 0,
|
||||
"blocked_tasks": [],
|
||||
"cycle": None,
|
||||
}
|
||||
@@ -416,8 +416,11 @@ def _qg0_errors(name: str, description: str) -> list:
|
||||
errors = []
|
||||
if not name or len(name) < 5:
|
||||
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
if len(name) > 80:
|
||||
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
if len(name) > settings.qg0_title_max:
|
||||
errors.append(
|
||||
f"Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 "
|
||||
f"(\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c {settings.qg0_title_max} \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)"
|
||||
)
|
||||
if not description or len(description.strip()) < 20:
|
||||
errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
|
||||
@@ -605,6 +608,17 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")
|
||||
|
||||
# ORCH-026 (B-1): import declared Plane `blocked-by` relations into job_deps
|
||||
# (only for task_deps_source = plane|hybrid; default `db` -> no-op, no Plane
|
||||
# call). Best-effort, never-raise: a Plane outage must not block the start.
|
||||
try:
|
||||
from .. import task_deps
|
||||
n = task_deps.ingest_plane_relations(task_id, plane_id, plane_project_id)
|
||||
if n:
|
||||
logger.info(f"Task {task_id}: ingested {n} blocked-by dependency edge(s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Task {task_id}: dependency ingestion skipped: {e}")
|
||||
|
||||
|
||||
async def handle_comment(data: dict, project_id: str = ""):
|
||||
"""Status-only verdict model: comments NEVER drive the pipeline.
|
||||
|
||||
@@ -94,4 +94,8 @@ def _disable_merge_verify(monkeypatch):
|
||||
"""
|
||||
from src import config as _cfg
|
||||
monkeypatch.setattr(_cfg.settings, "merge_verify_enabled", False, raising=False)
|
||||
# ORCH-073: the regression guard (check_main_regression) runs real git in
|
||||
# _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)
|
||||
yield
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_tc07_merge_actor_calls_gitea_merge(monkeypatch):
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
get_calls.append((url, params))
|
||||
return _Resp(200, [{"head": {"ref": branch}, "number": 7}])
|
||||
return _Resp(200, [{"head": {"ref": branch}, "base": {"ref": "main"}, "number": 7}])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
@@ -104,7 +104,7 @@ def test_tc09_never_raise_on_http_error(monkeypatch):
|
||||
def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}])
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 3}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict"))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
@@ -119,7 +119,7 @@ def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
def test_tc13_no_shell_out_no_force_push(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}])
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 9}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200))
|
||||
|
||||
|
||||
@@ -315,10 +315,17 @@ class _FakeResp:
|
||||
|
||||
|
||||
def test_tc16_pr_already_merged_true(monkeypatch):
|
||||
"""A merged PR -> True so a re-driven/reaped task is a no-op (no second merge)."""
|
||||
"""A merged code-PR -> True so a re-driven/reaped task is a no-op (no second merge).
|
||||
|
||||
ORCH-073 FR-2: the guard now counts a PR only when it carries THIS branch's code
|
||||
into main (merged & head.ref==branch & base.ref=="main").
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]),
|
||||
lambda *a, **k: _FakeResp(
|
||||
200,
|
||||
[{"number": 7, "merged": True, "head": {"ref": "feature/x"}, "base": {"ref": "main"}}],
|
||||
),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ def race_repo(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
|
||||
monkeypatch.setattr(qg.settings, "merge_gate_repos", repo)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
|
||||
# ORCH-026: this redrive test asserts the ORCH-043 ancestor-based short-circuit
|
||||
# ("already caught up" -> skip expensive re-test). Pin the always-rebase
|
||||
# kill-switch OFF so the legacy short-circuit path is exercised here; the new
|
||||
# default (True) is covered by tests/test_orch026_premerge_rebase.py (TC-A01).
|
||||
monkeypatch.setattr(qg.settings, "premerge_rebase_always", False, raising=False)
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
|
||||
@@ -204,7 +209,9 @@ def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return [{"merged": True}]
|
||||
# ORCH-073 FR-2: the guard counts a PR only when it carries THIS branch's
|
||||
# code into main (merged & head.ref==branch & base.ref=="main").
|
||||
return [{"merged": True, "head": {"ref": "feature/B"}, "base": {"ref": "main"}}]
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
|
||||
assert merge_gate.pr_already_merged(repo, "feature/B") is True
|
||||
|
||||
@@ -49,17 +49,22 @@ def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: PR.merged==true short-circuits to True even if git is unavailable.
|
||||
# TC-02 (ORCH-073 FR-1): PR.merged==true NO LONGER confirms a merge. The former
|
||||
# OR-branch was the phantom-merge root cause (a merged docs-PR turned verify green).
|
||||
# SHA-in-main is now the SINGLE criterion; an empty SHA -> inconclusive -> False.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_verify_true_when_pr_merged_even_without_git(monkeypatch):
|
||||
def test_tc02_pr_merged_does_not_confirm_without_sha_in_main(monkeypatch):
|
||||
# Even if a (docs-)PR is reported merged, that must NOT short-circuit to True.
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("git must NOT be consulted when PR is already merged")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is True
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
# SHA not an ancestor of origin/main (rc=1) -> not confirmed despite merged PR.
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
|
||||
)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
# And an empty SHA is inconclusive -> False (cannot prove SHA-in-main).
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -93,11 +98,13 @@ def test_tc04_verify_never_raises_on_git_error(monkeypatch):
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_verify_never_raises_on_http_error(monkeypatch):
|
||||
def boom(r, b):
|
||||
raise RuntimeError("gitea down")
|
||||
def test_tc04_verify_never_raises_on_worktree_error(monkeypatch):
|
||||
# ORCH-073: verify no longer consults pr_already_merged; a worktree/git error
|
||||
# on the SHA-in-main path is the failure to swallow -> conservative False.
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("worktree exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", boom)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
|
||||
118
tests/test_orch026_conditionality.py
Normal file
118
tests/test_orch026_conditionality.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""ORCH-026 conditionality / self-hosting safety (TC-A06, TC-A07).
|
||||
|
||||
TC-A06 kill-switch / out-of-scope: with the flag off (or for a repo outside the
|
||||
merge-gate scope) the merge path behaves 1:1 as before ORCH-026 — no-op.
|
||||
TC-A07 self-hosting safety: the new Level-A logic never pushes to main; the only
|
||||
force op stays --force-with-lease on the task branch; STAGE_TRANSITIONS
|
||||
and the QG_CHECKS registry are unchanged.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_cond.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
from src.qg import checks # noqa: E402
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A06
|
||||
def test_out_of_scope_repo_is_noop_even_with_flag_on(monkeypatch):
|
||||
"""A repo outside merge_gate scope -> N/A pass, regardless of premerge flag."""
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_repos", "orchestrator", raising=False)
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
|
||||
# enduro-trails is NOT in the scope -> no lease, no rebase, just N/A.
|
||||
called = {"acquire": 0, "rebase": 0}
|
||||
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
|
||||
lambda *a, **k: (called.__setitem__("acquire", called["acquire"] + 1), (True, "x"))[1],
|
||||
raising=False)
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main",
|
||||
lambda *a, **k: (called.__setitem__("rebase", called["rebase"] + 1), (True, "x"))[1],
|
||||
raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("enduro-trails", "ET-1", "feature/e")
|
||||
assert ok is True
|
||||
assert "N/A" in reason
|
||||
assert called["acquire"] == 0 and called["rebase"] == 0
|
||||
|
||||
|
||||
def test_task_deps_kill_switch_omits_gate(monkeypatch):
|
||||
"""task_deps_enabled=False -> claim_next_job query is the ORCH-1 query (no gate)."""
|
||||
import src.db as db
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", False, raising=False)
|
||||
# Inspect the SQL the claim builds by stubbing the connection.
|
||||
captured = {}
|
||||
|
||||
class _FakeConn:
|
||||
def execute(self, sql, *a):
|
||||
captured.setdefault("sql", sql)
|
||||
|
||||
class _R:
|
||||
def fetchone(self_inner):
|
||||
return None
|
||||
return _R()
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
|
||||
db.claim_next_job()
|
||||
assert "NOT EXISTS" not in captured["sql"], "gate must be omitted when disabled"
|
||||
|
||||
|
||||
def test_task_deps_enabled_adds_gate(monkeypatch):
|
||||
import src.db as db
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
captured = {}
|
||||
|
||||
class _FakeConn:
|
||||
def execute(self, sql, *a):
|
||||
captured.setdefault("sql", sql)
|
||||
|
||||
class _R:
|
||||
def fetchone(self_inner):
|
||||
return None
|
||||
return _R()
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
|
||||
db.claim_next_job()
|
||||
assert "NOT EXISTS" in captured["sql"], "gate must be present when enabled"
|
||||
assert "job_deps" in captured["sql"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A07
|
||||
def test_stage_transitions_unchanged():
|
||||
"""ORCH-026 must not touch the state machine (AC-A5)."""
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
# The canonical happy-path edges must still exist exactly.
|
||||
assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy"
|
||||
assert STAGE_TRANSITIONS["deploy"]["next"] == "done"
|
||||
assert STAGE_TRANSITIONS["development"]["next"] == "review"
|
||||
|
||||
|
||||
def test_qg_registry_has_no_new_dep_gate():
|
||||
"""The dependency gate is врезка in claim_next_job, NOT a registered QG."""
|
||||
from src.qg.checks import QG_CHECKS
|
||||
joined = " ".join(QG_CHECKS.keys())
|
||||
assert "task_dep" not in joined and "dependency" not in joined
|
||||
|
||||
|
||||
def test_premerge_only_force_with_lease_on_branch():
|
||||
"""auto_rebase_onto_main never pushes to main; force is --force-with-lease only."""
|
||||
import inspect
|
||||
src = inspect.getsource(merge_gate.auto_rebase_onto_main)
|
||||
assert "--force-with-lease" in src
|
||||
# No raw 'push origin main' / force-push to main in the rebase path.
|
||||
assert "push origin main" not in src
|
||||
assert "--force " not in src # plain --force (not -with-lease) is forbidden
|
||||
136
tests/test_orch026_dep_cycles.py
Normal file
136
tests/test_orch026_dep_cycles.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""ORCH-026 Level B — dependency cycle / deadlock detection (TC-B03, TC-B04).
|
||||
|
||||
TC-B03 detect_cycle is deterministic: A->B->A (and longer) is detected; an
|
||||
acyclic graph yields None. Pure function (edges injected).
|
||||
TC-B04 a detected cycle escalates: set_issue_blocked + a Telegram alert, with
|
||||
no worker crash and no blocking of other tasks (never-raise).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_cycles.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "cycles.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B03
|
||||
def test_detect_two_node_cycle():
|
||||
# edge (B, A) means "B depends on A"; 1->2 and 2->1 is a 2-cycle.
|
||||
edges = [(1, 2), (2, 1)]
|
||||
cyc = task_deps.detect_cycle(1, edges=edges)
|
||||
assert cyc is not None
|
||||
assert cyc[0] == cyc[-1] # closed cycle
|
||||
assert set(cyc) == {1, 2}
|
||||
|
||||
|
||||
def test_detect_longer_cycle():
|
||||
edges = [(1, 2), (2, 3), (3, 1)]
|
||||
cyc = task_deps.detect_cycle(1, edges=edges)
|
||||
assert cyc is not None
|
||||
assert set(cyc) >= {1, 2, 3}
|
||||
|
||||
|
||||
def test_acyclic_graph_has_no_cycle():
|
||||
edges = [(1, 2), (2, 3), (1, 3)] # DAG
|
||||
assert task_deps.detect_cycle(1, edges=edges) is None
|
||||
assert task_deps.find_any_cycle(edges=edges) is None
|
||||
|
||||
|
||||
def test_find_any_cycle_scans_whole_graph():
|
||||
# A disconnected cycle 10<->11 not reachable from node 1.
|
||||
edges = [(1, 2), (10, 11), (11, 10)]
|
||||
assert task_deps.detect_cycle(1, edges=edges) is None
|
||||
cyc = task_deps.find_any_cycle(edges=edges)
|
||||
assert cyc is not None
|
||||
assert set(cyc) == {10, 11}
|
||||
|
||||
|
||||
def test_detect_cycle_never_raises_on_garbage():
|
||||
assert task_deps.detect_cycle(None) is None
|
||||
# Malformed edge list -> swallowed -> None.
|
||||
assert task_deps.detect_cycle(1, edges="not-a-list") is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B04
|
||||
def _make_task(work_item_id, stage="development"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def test_handle_cycle_blocks_and_alerts(monkeypatch):
|
||||
a = _make_task("ORCH-60")
|
||||
b = _make_task("ORCH-61")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a) # cycle a<->b
|
||||
|
||||
blocked = []
|
||||
alerts = []
|
||||
import src.plane_sync as plane_sync
|
||||
import src.notifications as notifications
|
||||
monkeypatch.setattr(plane_sync, "set_issue_blocked",
|
||||
lambda wi, *a, **k: blocked.append(wi), raising=False)
|
||||
monkeypatch.setattr(notifications, "send_telegram",
|
||||
lambda text, *a, **k: alerts.append(text), raising=False)
|
||||
|
||||
cyc = task_deps.detect_cycle(a)
|
||||
assert cyc is not None
|
||||
ok = task_deps.handle_cycle(cyc)
|
||||
assert ok is True
|
||||
assert set(blocked) == {"ORCH-60", "ORCH-61"}
|
||||
assert len(alerts) == 1
|
||||
assert "ORCH-60" in alerts[0] and "ORCH-61" in alerts[0]
|
||||
|
||||
|
||||
def test_handle_cycle_never_raises_when_notify_fails(monkeypatch):
|
||||
a = _make_task("ORCH-70")
|
||||
b = _make_task("ORCH-71")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a)
|
||||
import src.plane_sync as plane_sync
|
||||
import src.notifications as notifications
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("plane down")
|
||||
|
||||
monkeypatch.setattr(plane_sync, "set_issue_blocked", _boom, raising=False)
|
||||
monkeypatch.setattr(notifications, "send_telegram", _boom, raising=False)
|
||||
cyc = task_deps.detect_cycle(a)
|
||||
# Must not propagate the exception (AC-G1).
|
||||
assert task_deps.handle_cycle(cyc) in (True, False)
|
||||
|
||||
|
||||
def test_declare_dependency_escalates_cycle(monkeypatch):
|
||||
"""declare_dependency surfaces a freshly-introduced cycle at declaration."""
|
||||
a = _make_task("ORCH-80")
|
||||
b = _make_task("ORCH-81")
|
||||
handled = []
|
||||
monkeypatch.setattr(task_deps, "handle_cycle",
|
||||
lambda cyc: handled.append(cyc), raising=False)
|
||||
assert task_deps.declare_dependency(a, b) is True
|
||||
assert handled == [] # no cycle yet
|
||||
# Closing the loop -> handle_cycle invoked.
|
||||
assert task_deps.declare_dependency(b, a) is True
|
||||
assert len(handled) == 1
|
||||
79
tests/test_orch026_dep_visibility.py
Normal file
79
tests/test_orch026_dep_visibility.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""ORCH-026 Level B — blocked-task visibility (TC-B06).
|
||||
|
||||
A dep-blocked task surfaces a waiting-line ("⏳ ждёт ORCH-NNN") in its single
|
||||
Telegram tracker card; the "one card per task" invariant is preserved (the line
|
||||
is added to the SAME render, not a new message). Render is never broken by the
|
||||
dependency lookup (never-raise).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_visibility.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import notifications # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "vis.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="development"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}",
|
||||
stage, f"title {work_item_id}"),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def test_blocked_task_shows_waiting_line():
|
||||
a = _make_task("ORCH-90", stage="development")
|
||||
b = _make_task("ORCH-91", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
text = notifications.render_task_tracker(b)
|
||||
assert "ждёт" in text
|
||||
assert "ORCH-90" in text
|
||||
|
||||
|
||||
def test_ready_task_has_no_waiting_line():
|
||||
a = _make_task("ORCH-92", stage="done")
|
||||
b = _make_task("ORCH-93", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
text = notifications.render_task_tracker(b)
|
||||
assert "ждёт" not in text
|
||||
|
||||
|
||||
def test_done_task_has_no_waiting_line():
|
||||
a = _make_task("ORCH-94", stage="development")
|
||||
b = _make_task("ORCH-95", stage="done")
|
||||
db.add_dependency(b, a)
|
||||
text = notifications.render_task_tracker(b)
|
||||
# A done task is terminal -> the waiting-line branch is skipped entirely.
|
||||
assert "ждёт" not in text
|
||||
|
||||
|
||||
def test_render_never_raises_on_dep_error(monkeypatch):
|
||||
b = _make_task("ORCH-96", stage="development")
|
||||
from src import task_deps
|
||||
monkeypatch.setattr(task_deps, "is_task_ready",
|
||||
lambda tid: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
raising=False)
|
||||
# Must still produce a card (no crash).
|
||||
text = notifications.render_task_tracker(b)
|
||||
assert "ORCH-96" in text
|
||||
124
tests/test_orch026_deps_integration.py
Normal file
124
tests/test_orch026_deps_integration.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""ORCH-026 Level B — declarative dependencies integration (TC-B08).
|
||||
|
||||
End-to-end (DB level): B declared blocked-by A; queued B does not start until A
|
||||
is 'done'; after A->done the worker can claim B. Also covers the plane/hybrid
|
||||
ingestion path: Plane `blocked-by` relations are resolved to local task ids and
|
||||
written into job_deps (the scheduler then reads only the DB).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_depsint.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "depsint.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="development", plane_id=None):
|
||||
conn = get_db()
|
||||
pid = plane_id or work_item_id
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(pid, work_item_id, "orchestrator", f"feature/{work_item_id}", stage, pid),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _set_stage(task_id, stage):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_b_waits_for_a_then_runs():
|
||||
a = _make_task("ORCH-200", stage="development")
|
||||
b = _make_task("ORCH-201", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
|
||||
# While A is in flight, B is not claimable.
|
||||
assert claim_next_job() is None
|
||||
ready, waiting = task_deps.is_task_ready(b)
|
||||
assert ready is False and "ORCH-200" in waiting
|
||||
|
||||
# A advances through to done.
|
||||
_set_stage(a, "review")
|
||||
assert claim_next_job() is None # still not terminal
|
||||
_set_stage(a, "done")
|
||||
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
def test_multiple_predecessors_all_must_be_done():
|
||||
a1 = _make_task("ORCH-210", stage="development")
|
||||
a2 = _make_task("ORCH-211", stage="development")
|
||||
b = _make_task("ORCH-212", stage="development")
|
||||
db.add_dependency(b, a1)
|
||||
db.add_dependency(b, a2)
|
||||
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b)
|
||||
|
||||
_set_stage(a1, "done")
|
||||
assert claim_next_job() is None, "still blocked by a2"
|
||||
_set_stage(a2, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# ---- plane/hybrid ingestion path (TC-B01) ---------------------------------
|
||||
def test_ingest_plane_relations_writes_db(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "hybrid", raising=False)
|
||||
a = _make_task("ORCH-220", stage="development", plane_id="plane-uuid-A")
|
||||
b = _make_task("ORCH-221", stage="development", plane_id="plane-uuid-B")
|
||||
|
||||
import src.plane_sync as plane_sync
|
||||
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
|
||||
lambda issue_id, project_id, **k: ["plane-uuid-A"],
|
||||
raising=False)
|
||||
n = task_deps.ingest_plane_relations(b, "plane-uuid-B", "proj-1")
|
||||
assert n == 1
|
||||
assert db.get_dependencies(b) == [a]
|
||||
|
||||
|
||||
def test_ingest_noop_when_source_db(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
|
||||
b = _make_task("ORCH-230", stage="development", plane_id="plane-uuid-Z")
|
||||
import src.plane_sync as plane_sync
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
|
||||
lambda *a, **k: called.__setitem__("n", called["n"] + 1) or [],
|
||||
raising=False)
|
||||
n = task_deps.ingest_plane_relations(b, "plane-uuid-Z", "proj-1")
|
||||
assert n == 0
|
||||
assert called["n"] == 0, "default db source must not call Plane"
|
||||
|
||||
|
||||
def test_ingest_never_raises_on_plane_outage(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "plane", raising=False)
|
||||
b = _make_task("ORCH-240", stage="development", plane_id="plane-uuid-Y")
|
||||
import src.plane_sync as plane_sync
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("plane down")
|
||||
|
||||
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids", _boom, raising=False)
|
||||
assert task_deps.ingest_plane_relations(b, "plane-uuid-Y", "proj-1") == 0
|
||||
95
tests/test_orch026_merge_serialize.py
Normal file
95
tests/test_orch026_merge_serialize.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""ORCH-026 Level A serialization (TC-A02..A05).
|
||||
|
||||
The merge-lease window (ORCH-043/065) is what serialises "merge -> main-updated"
|
||||
per repo; ORCH-026 reuses it unchanged. These tests confirm the properties the
|
||||
ADR relies on:
|
||||
|
||||
TC-A02 extended window: while A holds the lease, B of the SAME repo gets
|
||||
"merge-lock busy" -> defer (not rollback); holder-aware release does
|
||||
NOT delete A's lease.
|
||||
TC-A03 strict per-repo: an orchestrator lease never blocks an enduro-trails
|
||||
acquire (both claimable in parallel).
|
||||
TC-A04 restart-safe + proactive reclaim: a dead holder's lease is reclaimed
|
||||
(reclaim_stale_lease) so the pipeline never wedges forever.
|
||||
TC-A05 anti-livelock defer budget: merge_defer_max_attempts is bounded and
|
||||
positive -> exhaustion escalates instead of looping forever.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serialize.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leases_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True, raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A02
|
||||
def test_second_task_same_repo_defers_not_rollback(leases_dir):
|
||||
okA, reasonA = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
assert okA is True
|
||||
|
||||
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is False
|
||||
assert reasonB == "merge-lock busy" # -> caller DEFERS, never a rollback signal
|
||||
|
||||
|
||||
def test_holder_aware_release_keeps_foreign_lease(leases_dir):
|
||||
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
# A delayed release from B (which never held it) must NOT delete A's lease.
|
||||
merge_gate.release_merge_lease("orchestrator", "feature/B")
|
||||
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is False and reasonB == "merge-lock busy"
|
||||
# A's own release frees it.
|
||||
merge_gate.release_merge_lease("orchestrator", "feature/A")
|
||||
okB2, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB2 is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A03
|
||||
def test_serialization_is_strictly_per_repo(leases_dir):
|
||||
okA, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
okET, _ = merge_gate.acquire_merge_lease("enduro-trails", "feature/E", "ET-1")
|
||||
assert okA is True
|
||||
assert okET is True, "a different repo must be claimable in parallel (AC-A3)"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A04
|
||||
def test_dead_holder_lease_is_reclaimed(leases_dir, monkeypatch):
|
||||
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
# Holder pid is THIS process; simulate it being dead.
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False, raising=False)
|
||||
reclaimed = merge_gate.reclaim_stale_lease("orchestrator")
|
||||
assert reclaimed is True
|
||||
# After reclaim B can acquire -> pipeline does not wedge forever.
|
||||
okB, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is True
|
||||
|
||||
|
||||
def test_stale_lease_age_reclaimed_on_acquire(leases_dir, monkeypatch):
|
||||
# A very short timeout makes the existing lease look stale on B's acquire.
|
||||
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 0, raising=False)
|
||||
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is True
|
||||
assert "reclaimed" in reasonB
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A05
|
||||
def test_defer_budget_is_bounded(monkeypatch):
|
||||
"""The defer budget is a positive finite int -> exhaustion escalates (AC-A6)."""
|
||||
from src.config import settings
|
||||
assert isinstance(settings.merge_defer_max_attempts, int)
|
||||
assert settings.merge_defer_max_attempts > 0
|
||||
assert settings.merge_defer_delay_s > 0
|
||||
83
tests/test_orch026_migration.py
Normal file
83
tests/test_orch026_migration.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""ORCH-026 — additive job_deps migration (TC-G01, AC-G4).
|
||||
|
||||
The migration must be additive (CREATE TABLE/INDEX IF NOT EXISTS), idempotent,
|
||||
and safe on a pre-existing DB with data: existing columns of jobs/tasks/
|
||||
agent_runs/events are untouched.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_migration.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbfile(tmp_path, monkeypatch):
|
||||
f = tmp_path / "mig.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(f))
|
||||
return f
|
||||
|
||||
|
||||
def _columns(conn, table):
|
||||
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
|
||||
|
||||
def test_job_deps_table_created(dbfile):
|
||||
init_db()
|
||||
conn = get_db()
|
||||
cols = _columns(conn, "job_deps")
|
||||
conn.close()
|
||||
assert set(cols) == {"task_id", "depends_on_task_id", "created_at"}
|
||||
|
||||
|
||||
def test_job_deps_indices_created(dbfile):
|
||||
init_db()
|
||||
conn = get_db()
|
||||
idx = {r[1] for r in conn.execute("PRAGMA index_list(job_deps)").fetchall()}
|
||||
conn.close()
|
||||
assert "idx_job_deps_task" in idx
|
||||
assert "idx_job_deps_depends" in idx
|
||||
|
||||
|
||||
def test_primary_key_idempotent_insert(dbfile):
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
|
||||
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
|
||||
conn.commit()
|
||||
n = conn.execute("SELECT COUNT(*) FROM job_deps").fetchone()[0]
|
||||
conn.close()
|
||||
assert n == 1, "PK (task_id, depends_on_task_id) prevents dup rows"
|
||||
|
||||
|
||||
def test_migration_idempotent_and_preserves_data(dbfile):
|
||||
# First init + seed legacy data.
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES ('ET-1','ET-1','enduro-trails','feature/x','development')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, status) VALUES ('developer','enduro-trails','queued')"
|
||||
)
|
||||
conn.commit()
|
||||
tasks_cols_before = _columns(conn, "tasks")
|
||||
jobs_cols_before = _columns(conn, "jobs")
|
||||
conn.close()
|
||||
|
||||
# Re-run init_db (simulates a restart on a live DB) -> must be a no-op.
|
||||
init_db()
|
||||
conn = get_db()
|
||||
assert _columns(conn, "tasks") == tasks_cols_before, "tasks columns unchanged"
|
||||
assert _columns(conn, "jobs") == jobs_cols_before, "jobs columns unchanged"
|
||||
# Legacy data survives.
|
||||
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
|
||||
conn.close()
|
||||
82
tests/test_orch026_premerge_rebase.py
Normal file
82
tests/test_orch026_premerge_rebase.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""ORCH-026 Level A (TC-A01): proactive pre-merge rebase.
|
||||
|
||||
check_branch_mergeable must ALWAYS rebase the task branch onto the current
|
||||
origin/main under the held merge-lease when ``premerge_rebase_always`` is on —
|
||||
even when ``branch_is_behind_main`` would short-circuit (no conflict, formally
|
||||
not behind). With the flag OFF the ORCH-043 short-circuit is restored 1:1.
|
||||
|
||||
These are pure unit tests: every merge_gate primitive is monkeypatched, so no
|
||||
git/network is touched — we assert the CONTROL FLOW (was auto_rebase_onto_main
|
||||
called?) and the verdict.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_premerge.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
from src.qg import checks # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_gate(monkeypatch):
|
||||
"""Patch merge_gate primitives; record whether auto_rebase ran."""
|
||||
calls = {"rebase": 0, "retest": 0, "released": 0, "behind_checked": 0}
|
||||
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
|
||||
|
||||
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
|
||||
lambda *a, **k: (True, "lease acquired"), raising=False)
|
||||
|
||||
def _behind(repo, branch):
|
||||
calls["behind_checked"] += 1
|
||||
return False # NOT behind -> ORCH-043 would short-circuit
|
||||
|
||||
def _rebase(repo, branch):
|
||||
calls["rebase"] += 1
|
||||
return True, "rebased (noop)"
|
||||
|
||||
def _retest(repo, branch):
|
||||
calls["retest"] += 1
|
||||
return True, "green"
|
||||
|
||||
def _release(repo, branch=None):
|
||||
calls["released"] += 1
|
||||
|
||||
monkeypatch.setattr(merge_gate, "branch_is_behind_main", _behind, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "retest_branch", _retest, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "release_merge_lease", _release, raising=False)
|
||||
return calls
|
||||
|
||||
|
||||
def test_always_rebases_even_when_not_behind(patched_gate, monkeypatch):
|
||||
"""premerge_rebase_always=True -> auto_rebase_onto_main ALWAYS called (AC-A2)."""
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
|
||||
assert ok is True
|
||||
assert patched_gate["rebase"] == 1, "rebase must run even when not behind"
|
||||
assert patched_gate["retest"] == 1, "re-test must run after the proactive rebase"
|
||||
|
||||
|
||||
def test_flag_off_short_circuits_like_orch043(patched_gate, monkeypatch):
|
||||
"""premerge_rebase_always=False -> not-behind short-circuit, no rebase (AC-A7)."""
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", False, raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
|
||||
assert ok is True
|
||||
assert reason == "branch up-to-date with main"
|
||||
assert patched_gate["rebase"] == 0, "must NOT rebase when not behind and flag off"
|
||||
|
||||
|
||||
def test_disabled_gate_is_noop(monkeypatch):
|
||||
"""merge_gate_enabled=False -> pass-through, no lease/rebase at all (AC-G2)."""
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", False, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
|
||||
assert ok is True
|
||||
assert "disabled" in reason
|
||||
90
tests/test_orch026_queue_observability.py
Normal file
90
tests/test_orch026_queue_observability.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""ORCH-026 — /queue task_deps observability (TC-G02, G-2).
|
||||
|
||||
task_deps.snapshot() is a read-only summary (NOT a source of truth) exposing the
|
||||
declared edges, blocked tasks and any detected cycle. It must never raise.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_queue_obs.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "obs.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="development"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def test_snapshot_shape_empty():
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["enabled"] is True
|
||||
assert snap["source"] == "db"
|
||||
assert snap["edges"] == 0
|
||||
assert snap["blocked_tasks"] == []
|
||||
assert snap["cycle"] is None
|
||||
|
||||
|
||||
def test_snapshot_reports_blocked_task():
|
||||
a = _make_task("ORCH-100", stage="development")
|
||||
b = _make_task("ORCH-101", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["edges"] == 1
|
||||
assert len(snap["blocked_tasks"]) == 1
|
||||
bt = snap["blocked_tasks"][0]
|
||||
assert bt["work_item_id"] == "ORCH-101"
|
||||
assert "ORCH-100" in bt["waiting_on"]
|
||||
assert snap["cycle"] is None
|
||||
|
||||
|
||||
def test_snapshot_reports_cycle():
|
||||
a = _make_task("ORCH-102")
|
||||
b = _make_task("ORCH-103")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["cycle"] is not None
|
||||
assert "ORCH-102" in snap["cycle"] or "ORCH-103" in snap["cycle"]
|
||||
|
||||
|
||||
def test_snapshot_never_raises(monkeypatch):
|
||||
monkeypatch.setattr(db, "get_dependency_edges",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
raising=False)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["edges"] == 0
|
||||
assert snap["blocked_tasks"] == []
|
||||
|
||||
|
||||
def test_queue_endpoint_includes_task_deps(monkeypatch):
|
||||
"""GET /queue payload carries the task_deps block (read-only)."""
|
||||
import asyncio
|
||||
from src import main
|
||||
payload = asyncio.run(main.queue())
|
||||
assert "task_deps" in payload
|
||||
assert "enabled" in payload["task_deps"]
|
||||
65
tests/test_orch026_serialize_integration.py
Normal file
65
tests/test_orch026_serialize_integration.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""ORCH-026 Level A — serialization integration (TC-A08).
|
||||
|
||||
Scenario (no network, lease + gate level): two tasks of the SAME repo race for
|
||||
the merge edge. While A holds the merge-lease (the merge->main-updated window),
|
||||
B's check_branch_mergeable returns "merge-lock busy" -> the engine DEFERS B (it
|
||||
does NOT roll back). After A releases (A reached main / done), B acquires, is
|
||||
proactively rebased onto the now-current main (carrying A's code) and merges.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serint.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
from src.qg import checks # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
# Make the git/test primitives deterministic no-ops; A's rebase is a no-op,
|
||||
# B's rebase is the real "catch up to A's code".
|
||||
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", lambda r, b: (True, "ok"), raising=False)
|
||||
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "green"), raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_serialized_merge_window(env, monkeypatch):
|
||||
repo = "orchestrator"
|
||||
# A reaches the merge edge first: gate passes and HOLDS the lease.
|
||||
okA, reasonA = checks.check_branch_mergeable(repo, "ORCH-1", "feature/A")
|
||||
assert okA is True
|
||||
# Lease is held by A.
|
||||
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
|
||||
|
||||
# B reaches the merge edge while A still holds the window -> busy -> DEFER.
|
||||
okB, reasonB = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
|
||||
assert okB is False
|
||||
assert reasonB == "merge-lock busy" # NOT a rollback; engine re-queues via available_at
|
||||
# B's defer must NOT have stolen / cleared A's lease.
|
||||
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
|
||||
|
||||
# A completes (PR merged / deploy->done) -> lease released.
|
||||
merge_gate.release_merge_lease(repo, "feature/A")
|
||||
|
||||
# B retries: now acquires, is proactively rebased onto current main, merges.
|
||||
rebased = {"called": 0}
|
||||
|
||||
def _rebase(r, b):
|
||||
rebased["called"] += 1
|
||||
return True, "rebased onto A"
|
||||
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
|
||||
okB2, reasonB2 = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
|
||||
assert okB2 is True
|
||||
assert rebased["called"] == 1, "B must be proactively rebased onto the fresh main (A's code)"
|
||||
157
tests/test_orch026_task_deps.py
Normal file
157
tests/test_orch026_task_deps.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""ORCH-026 Level B — declarative task dependencies (TC-B01/B02/B05/B07).
|
||||
|
||||
Real SQLite (tmp db). We drive tasks + job_deps directly and assert:
|
||||
TC-B01 add_dependency declares an edge; get_dependencies resolves it; a
|
||||
self-edge is rejected; never-raise on a bad input.
|
||||
TC-B02 is_task_ready: a task with an un-done predecessor is NOT ready; when
|
||||
every predecessor reaches 'done' it becomes ready.
|
||||
TC-B05 claim_next_job does NOT claim a dep-blocked job (no slot taken); once
|
||||
the predecessor is 'done' the job becomes claimable.
|
||||
TC-B07 reconciler skip helper: is_task_ready=False is honoured (the gate task
|
||||
is left waiting).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_task_deps.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "deps.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(stage="development", work_item_id="ORCH-1", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _set_stage(task_id, stage):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B01
|
||||
def test_add_dependency_declares_and_resolves():
|
||||
a = _make_task(work_item_id="ORCH-10", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-11", stage="development")
|
||||
assert db.add_dependency(b, a) is True
|
||||
assert db.get_dependencies(b) == [a]
|
||||
# Idempotent: re-declaring the same edge is a no-op.
|
||||
assert db.add_dependency(b, a) is False
|
||||
|
||||
|
||||
def test_self_edge_rejected():
|
||||
a = _make_task(work_item_id="ORCH-12")
|
||||
assert db.add_dependency(a, a) is False
|
||||
assert db.get_dependencies(a) == []
|
||||
|
||||
|
||||
def test_add_dependency_never_raises_on_bad_input():
|
||||
assert db.add_dependency(None, 1) is False
|
||||
assert db.add_dependency(1, None) is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B02
|
||||
def test_is_task_ready_blocked_then_ready():
|
||||
a = _make_task(work_item_id="ORCH-20", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-21", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
ready, waiting = task_deps.is_task_ready(b)
|
||||
assert ready is False
|
||||
assert "ORCH-20" in waiting
|
||||
|
||||
_set_stage(a, "done")
|
||||
ready2, waiting2 = task_deps.is_task_ready(b)
|
||||
assert ready2 is True
|
||||
assert waiting2 == []
|
||||
|
||||
|
||||
def test_is_task_ready_no_deps_is_ready():
|
||||
a = _make_task(work_item_id="ORCH-22")
|
||||
ready, waiting = task_deps.is_task_ready(a)
|
||||
assert ready is True and waiting == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B05
|
||||
def test_claim_skips_dep_blocked_job():
|
||||
a = _make_task(work_item_id="ORCH-30", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-31", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
|
||||
# B is blocked by un-done A -> claim must NOT pick it (no slot taken).
|
||||
claimed = claim_next_job()
|
||||
assert claimed is None, "dep-blocked job must not be claimed"
|
||||
|
||||
# A finishes -> B becomes claimable.
|
||||
_set_stage(a, "done")
|
||||
claimed2 = claim_next_job()
|
||||
assert claimed2 is not None
|
||||
assert claimed2["id"] == job_b
|
||||
|
||||
|
||||
def test_claim_prefers_unblocked_job_over_blocked():
|
||||
a = _make_task(work_item_id="ORCH-40", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-41", stage="development")
|
||||
c = _make_task(work_item_id="ORCH-42", stage="development")
|
||||
db.add_dependency(b, a) # b blocked by a
|
||||
|
||||
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b) # older id
|
||||
job_c = enqueue_job("developer", "orchestrator", "C", task_id=c) # not blocked
|
||||
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None
|
||||
assert claimed["id"] == job_c, "blocked B skipped, unblocked C claimed"
|
||||
assert job_b # referenced
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B07
|
||||
def test_reconciler_skip_helper_honours_block(monkeypatch):
|
||||
"""The reconciler reads is_task_ready; a not-ready task must be skipped."""
|
||||
from src import reconciler as rec
|
||||
a = _make_task(work_item_id="ORCH-50", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-51", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
advanced = {"called": False}
|
||||
monkeypatch.setattr(rec, "advance_if_gate_passed",
|
||||
lambda *a, **k: advanced.__setitem__("called", True),
|
||||
raising=False)
|
||||
monkeypatch.setattr(rec, "has_active_job_for_task", lambda tid: False, raising=False)
|
||||
monkeypatch.setattr(rec, "developer_retry_count", lambda tid: 0, raising=False)
|
||||
monkeypatch.setattr(rec.settings, "task_deps_enabled", True, raising=False)
|
||||
monkeypatch.setattr(rec.settings, "reconcile_enabled", True, raising=False)
|
||||
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)
|
||||
|
||||
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
|
||||
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}
|
||||
r._reconcile_gate_task(task_row)
|
||||
assert advanced["called"] is False, "dep-blocked task must not be advanced (B-5)"
|
||||
93
tests/test_orch073_conditionality.py
Normal file
93
tests/test_orch073_conditionality.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""ORCH-073 — conditionality / backward-compat (INV-5).
|
||||
|
||||
Covers TC-17/18 / AC-6. The whole under-gate and the regression guard are no-ops for
|
||||
non-self repos and when their kill-switches are off, so enduro-trails and a disabled
|
||||
self-host behave exactly as before.
|
||||
"""
|
||||
|
||||
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_orch073_cond.db"))
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
from src import merge_gate, stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-073"
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 (AC-6/INV-5): non-self repo / kill-switch off -> under-gate is a no-op.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_merge_verify_applies_scope(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "")
|
||||
# Empty CSV -> only the self-hosting repo.
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is True
|
||||
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
||||
# Kill-switch off -> no-op for everyone.
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc17_under_gate_noop_for_non_self(monkeypatch):
|
||||
# When the under-gate does not apply, _handle_merge_verify advances (False) and
|
||||
# never touches the merge-actor / verifier / guard.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("under-gate must be a no-op for non-self repos")
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", must_not_call)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", must_not_call)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", must_not_call)
|
||||
|
||||
res = AdvanceResult()
|
||||
assert _handle_merge_verify(1, "enduro-trails", WI, BRANCH, res) is False
|
||||
assert res.alerted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 (INV-5): regression guard respects its kill-switch -> no-op; SHA-in-main
|
||||
# alone still advances the task.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_guard_kill_switch_skips_guard(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when disabled")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
res = AdvanceResult()
|
||||
# Guard disabled -> confirmed SHA-in-main advances straight to done (return False).
|
||||
assert _handle_merge_verify(1, REPO, WI, BRANCH, res) is False
|
||||
assert res.alerted is False
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
def test_tc18_guard_noop_for_non_self_repo(monkeypatch):
|
||||
# check_main_regression is only invoked inside the confirmed branch which itself
|
||||
# only runs when merge_verify_applies is True (self-hosting / CSV). For a non-self
|
||||
# repo the guard is never reached.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run for non-self")),
|
||||
)
|
||||
res = AdvanceResult()
|
||||
assert _handle_merge_verify(1, "enduro-trails", WI, BRANCH, res) is False
|
||||
85
tests/test_orch073_gitattributes.py
Normal file
85
tests/test_orch073_gitattributes.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""ORCH-073 FR-4 — .gitattributes: CHANGELOG.md merge=union.
|
||||
|
||||
Covers TC-11/TC-12 / AC-4. TC-11 asserts the repo-root .gitattributes declares the
|
||||
union driver (git check-attr). TC-12 proves, in a throwaway git repo, that two
|
||||
branches both editing '## [Unreleased]' merge WITHOUT a conflict and BOTH entries
|
||||
survive — exactly what stops auto_rebase_onto_main from rolling a branch back.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _git(cwd, *args, env=None):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=str(cwd), capture_output=True, text=True, env=env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-4): the repo-root .gitattributes declares CHANGELOG.md merge=union.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_gitattributes_declares_union():
|
||||
ga = REPO_ROOT / ".gitattributes"
|
||||
assert ga.is_file(), ".gitattributes must exist at the repo root"
|
||||
assert "CHANGELOG.md merge=union" in ga.read_text(encoding="utf-8")
|
||||
|
||||
r = _git(REPO_ROOT, "check-attr", "merge", "CHANGELOG.md")
|
||||
assert r.returncode == 0, r.stderr
|
||||
# Output form: 'CHANGELOG.md: merge: union'
|
||||
assert "merge: union" in r.stdout, r.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 (AC-4): two Unreleased edits merge with no conflict; both kept.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _init_repo(tmp_path):
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
"PATH": __import__("os").environ.get("PATH", ""),
|
||||
"HOME": str(tmp_path),
|
||||
}
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
assert _git(repo, "init", "-b", "main", env=env).returncode == 0
|
||||
(repo / ".gitattributes").write_text("CHANGELOG.md merge=union\n", encoding="utf-8")
|
||||
base = (
|
||||
"# Changelog\n\n## [Unreleased]\n\n### Common\n\n## [0.1.0]\n- initial\n"
|
||||
)
|
||||
(repo / "CHANGELOG.md").write_text(base, encoding="utf-8")
|
||||
_git(repo, "add", ".", env=env)
|
||||
assert _git(repo, "commit", "-m", "base", env=env).returncode == 0
|
||||
return repo, env
|
||||
|
||||
|
||||
def test_tc12_union_merge_keeps_both_entries(tmp_path):
|
||||
repo, env = _init_repo(tmp_path)
|
||||
|
||||
# Branch A adds its Unreleased line.
|
||||
_git(repo, "checkout", "-b", "task-a", env=env)
|
||||
txt = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
(repo / "CHANGELOG.md").write_text(
|
||||
txt.replace("### Common\n", "### Common\n- ORCH-A: feature A\n"), encoding="utf-8"
|
||||
)
|
||||
_git(repo, "commit", "-am", "task A changelog", env=env)
|
||||
|
||||
# Branch B (from main) adds a DIFFERENT Unreleased line at the same spot.
|
||||
_git(repo, "checkout", "main", env=env)
|
||||
_git(repo, "checkout", "-b", "task-b", env=env)
|
||||
txt = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
(repo / "CHANGELOG.md").write_text(
|
||||
txt.replace("### Common\n", "### Common\n- ORCH-B: feature B\n"), encoding="utf-8"
|
||||
)
|
||||
_git(repo, "commit", "-am", "task B changelog", env=env)
|
||||
|
||||
# Merge A into B — union must avoid a conflict and keep BOTH lines.
|
||||
m = _git(repo, "merge", "--no-edit", "task-a", env=env)
|
||||
result = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert m.returncode == 0, f"union merge must not conflict: {m.stdout}\n{m.stderr}"
|
||||
assert "<<<<<<<" not in result and ">>>>>>>" not in result
|
||||
assert "ORCH-A: feature A" in result
|
||||
assert "ORCH-B: feature B" in result
|
||||
106
tests/test_orch073_merge_pr.py
Normal file
106
tests/test_orch073_merge_pr.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""ORCH-073 FR-3 — merge_pr merges exactly the feature code-PR (base==main).
|
||||
|
||||
Covers TC-08..10 / AC-7 / INV-2/INV-4. The actor selects the open PR with
|
||||
head==branch AND base==main (never an auto docs-PR / foreign base), merges via the
|
||||
Gitea PR-merge API only (no push/force-push), and is idempotent on an already-merged
|
||||
code-PR. Gitea HTTP is mocked; never-raise -> (False, reason).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
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, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08: open code-PR (head==branch, base==main) -> POST /pulls/{n}/merge.
|
||||
# A concurrently-open docs-PR (head=docs/*) must be skipped.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_merges_code_pr_not_docs_pr(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
post_calls = []
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
return _Resp(200, [
|
||||
{"head": {"ref": "docs/ORCH-073-log"}, "base": {"ref": "main"}, "number": 4},
|
||||
{"head": {"ref": BRANCH}, "base": {"ref": "main"}, "number": 7},
|
||||
])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
return _Resp(200)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is True and "PR #7" in msg
|
||||
assert len(post_calls) == 1
|
||||
url, body = post_calls[0]
|
||||
assert url.endswith("/repos/admin/orchestrator/pulls/7/merge")
|
||||
assert body == {"Do": "merge"}
|
||||
|
||||
|
||||
def test_tc08_skips_pr_onto_non_main_base(monkeypatch):
|
||||
# Right head but base != main -> not a merge-to-main code-PR -> no open PR.
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _Resp(200, [{"head": {"ref": BRANCH}, "base": {"ref": "develop"}, "number": 9}]),
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("must not POST merge for a non-main base PR")))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is False and msg == "no open PR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (INV-2): no open code-PR -> (False, "no open PR"); never shells out.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_no_open_pr_no_shell_out(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, []))
|
||||
subprocess_calls = []
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: subprocess_calls.append(cmd),
|
||||
)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is False and msg == "no open PR"
|
||||
# No git push/force-push (or any subprocess) for the merge-actor.
|
||||
assert subprocess_calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 (AC-7/INV-4): already-merged code-PR -> no-op, no second POST merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_idempotent_already_merged(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("no Gitea call when the code-PR is already merged")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", must_not_call)
|
||||
monkeypatch.setattr(httpx, "post", must_not_call)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is True and msg == "already-merged"
|
||||
99
tests/test_orch073_merge_verify.py
Normal file
99
tests/test_orch073_merge_verify.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""ORCH-073 FR-1 — verify_merged_to_main: SHA-in-main is the SINGLE criterion.
|
||||
|
||||
Covers TC-01..04 / AC-2 / AC-6. The former OR-branch `pr_already_merged` was the
|
||||
phantom-merge root cause and is removed: a merged docs-PR must NOT confirm a merge.
|
||||
git/HTTP are mocked; the verifier honours the never-raise contract (INV-1).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
|
||||
class _R:
|
||||
"""Minimal completed-subprocess stand-in (returncode only)."""
|
||||
|
||||
def __init__(self, rc):
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 (AC-6): sha is an ancestor of origin/main (merge-base rc=0) -> True.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_true_when_sha_is_ancestor(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
calls.append(cmd)
|
||||
return _R(0) # fetch ok; merge-base --is-ancestor -> 0 (ancestor)
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is True
|
||||
assert any(
|
||||
"merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 (AC-2): sha NOT in main AND a merged docs-PR exists -> False.
|
||||
# This is the exact ORCH-067/069 bug: a merged docs-PR must not confirm.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_false_when_sha_not_in_main_even_with_merged_docs_pr(monkeypatch):
|
||||
# A merged docs-PR is present (mock returns True), but it must be IGNORED.
|
||||
called = {"pr": False}
|
||||
|
||||
def fake_pr_already_merged(r, b):
|
||||
called["pr"] = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", fake_pr_already_merged)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
|
||||
)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
# The merged-PR signal is no longer consulted by the verifier at all.
|
||||
assert called["pr"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03: empty sha -> inconclusive -> False (fail-closed), no git consulted.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_empty_sha_is_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("git must NOT run for an empty SHA")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (INV-1): a git/OS error -> False, exception never propagated.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_never_raises_on_git_error(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("git exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_never_raises_on_worktree_error(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("worktree down")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
78
tests/test_orch073_pr_classify.py
Normal file
78
tests/test_orch073_pr_classify.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""ORCH-073 FR-2 — pr_already_merged distinguishes code-PR from docs-PR.
|
||||
|
||||
Covers TC-05..07. pr_already_merged is now an idempotency-guard: it counts a PR as
|
||||
"merged" ONLY when it carries the code of THIS feature-branch into main
|
||||
(merged & head.ref==branch & base.ref=="main"), excluding auto docs-PRs. Gitea HTTP
|
||||
is mocked; never-raise -> False (INV-1).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05: a merged docs-PR (head=docs/*, base=main) is NOT counted as code-merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_merged_docs_pr_not_counted(monkeypatch):
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": "docs/ORCH-073-staging-log"}, "base": {"ref": "main"}},
|
||||
{"merged": False, "head": {"ref": BRANCH}, "base": {"ref": "main"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06: a merged code-PR (head==branch, base==main) IS recognised.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_merged_code_pr_recognised(monkeypatch):
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": BRANCH}, "base": {"ref": "main"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is True
|
||||
|
||||
|
||||
def test_tc06_merged_code_pr_onto_non_main_base_not_counted(monkeypatch):
|
||||
# Right head but a foreign base (not main) must NOT count.
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": BRANCH}, "base": {"ref": "develop"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07: HTTP error / non-200 -> False (never-raise, conservative).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_non_200_is_false(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(500, []))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
def test_tc07_http_exception_is_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise httpx.ConnectError("gitea unreachable")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
114
tests/test_orch073_regression_guard.py
Normal file
114
tests/test_orch073_regression_guard.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""ORCH-073 FR-5 — main-integrity regression guard wired into _handle_merge_verify.
|
||||
|
||||
Covers TC-13..16 / AC-3 / AC-5 / AC-6 / INV-1. Calls the 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).
|
||||
"""
|
||||
|
||||
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_orch073_rg.db"))
|
||||
|
||||
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-073"
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _wire(monkeypatch):
|
||||
# Under-gate is in scope for the self-hosting repo; guard enabled.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", True)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
||||
# 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-13 (AC-6): SHA in main AND markers intact -> advance (return False), no alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_confirmed_and_intact_advances(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", lambda r, b: (True, "markers intact (4)"))
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert res.alerted is False
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 (AC-3): SHA NOT in main (docs-only merge) -> HOLD + alert + Blocked.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_sha_not_in_main_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
# Guard must never even run when SHA is not confirmed.
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when not confirmed")),
|
||||
)
|
||||
|
||||
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
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15 (AC-5): SHA in main BUT a marker missing -> HOLD + 'main regressed' alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_marker_missing_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (False, "main regressed: ORCH-067 code missing (plane_issue_link @ src/notifications.py)"),
|
||||
)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD, NOT done
|
||||
assert res.advanced is False
|
||||
assert res.note == "main-regressed-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 (INV-1): an internal verifier error -> HOLD + alert, no exception escapes.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_internal_error_holds_never_raises(monkeypatch):
|
||||
def boom(r, b, s):
|
||||
raise RuntimeError("verifier exploded")
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", boom)
|
||||
|
||||
res = AdvanceResult()
|
||||
# Must NOT raise.
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.alerted is True
|
||||
assert "merge-verify-error" in (res.note or "")
|
||||
117
tests/test_qg0_title_limit.py
Normal file
117
tests/test_qg0_title_limit.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""ORCH-069: unit tests for the configurable QG-0 title-length limit.
|
||||
|
||||
Covers `_qg0_errors` (src/webhooks/plane.py) reading the upper title limit
|
||||
dynamically from `settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, default 200),
|
||||
plus the graceful env-degradation field-validator on `Settings`.
|
||||
|
||||
The tests patch `src.config.settings.qg0_title_max` (the same object imported into
|
||||
`src.webhooks.plane`) and assert boundary behaviour and error texts. For env-driven
|
||||
cases a FRESH `Settings()` instance is created locally, since the module-level
|
||||
singleton is built once on import.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import Settings, settings
|
||||
from src.webhooks.plane import _qg0_errors
|
||||
|
||||
VALID_DESCRIPTION = "x" * 30 # >= 20 chars, always passes the description check
|
||||
|
||||
|
||||
def _title_length_error(errors):
|
||||
"""Return the title length-limit error string, or None if absent.
|
||||
|
||||
The short-title error ('нужно >= 5') and the description error are excluded;
|
||||
only the 'too long' title error is matched (it contains 'максимум').
|
||||
"""
|
||||
for e in errors:
|
||||
if "Title" in e and "максимум" in e:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# --- AC-1: default limit 200, boundary at 201 ------------------------------
|
||||
|
||||
def test_tc01_default_limit_200_boundary_pass(monkeypatch):
|
||||
"""TC-01: title of exactly 200 chars -> no title length error (PASS)."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("x" * 200, VALID_DESCRIPTION)
|
||||
assert _title_length_error(errors) is None
|
||||
|
||||
|
||||
def test_tc02_default_limit_200_boundary_fail(monkeypatch):
|
||||
"""TC-02: title of 201 chars -> length error mentioning '200'."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("x" * 201, VALID_DESCRIPTION)
|
||||
err = _title_length_error(errors)
|
||||
assert err is not None
|
||||
assert "200" in err
|
||||
|
||||
|
||||
# --- AC-2: configurable limit 120, boundary at 121 -------------------------
|
||||
|
||||
def test_tc03_custom_limit_120_boundary_pass(monkeypatch):
|
||||
"""TC-03: with limit 120, a 120-char title passes."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 120)
|
||||
errors = _qg0_errors("x" * 120, VALID_DESCRIPTION)
|
||||
assert _title_length_error(errors) is None
|
||||
|
||||
|
||||
def test_tc04_custom_limit_120_boundary_fail(monkeypatch):
|
||||
"""TC-04: with limit 120, a 121-char title fails; text mentions 120 not 80."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 120)
|
||||
errors = _qg0_errors("x" * 121, VALID_DESCRIPTION)
|
||||
err = _title_length_error(errors)
|
||||
assert err is not None
|
||||
assert "120" in err
|
||||
assert "80" not in err
|
||||
|
||||
|
||||
# --- AC-3: graceful handling of invalid/empty env --------------------------
|
||||
|
||||
def test_tc05_graceful_non_numeric_env(monkeypatch):
|
||||
"""TC-05: non-numeric env -> Settings() does not raise, limit == 200."""
|
||||
monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "abc")
|
||||
s = Settings()
|
||||
assert s.qg0_title_max == 200
|
||||
|
||||
|
||||
def test_tc06_graceful_empty_env(monkeypatch):
|
||||
"""TC-06: empty-string env -> default 200, no exception."""
|
||||
monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "")
|
||||
s = Settings()
|
||||
assert s.qg0_title_max == 200
|
||||
|
||||
|
||||
def test_tc07_valid_numeric_env(monkeypatch):
|
||||
"""TC-07: valid numeric env -> the given value is applied (positive path)."""
|
||||
monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "150")
|
||||
s = Settings()
|
||||
assert s.qg0_title_max == 150
|
||||
|
||||
|
||||
# --- AC-4: lower limits unchanged ------------------------------------------
|
||||
|
||||
def test_tc08_short_title_still_errors(monkeypatch):
|
||||
"""TC-08: title < 5 chars still raises the short-title error."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("abc", VALID_DESCRIPTION)
|
||||
assert any("Title" in e and "нужно >= 5" in e for e in errors)
|
||||
|
||||
|
||||
def test_tc09_short_description_still_errors(monkeypatch):
|
||||
"""TC-09: description < 20 chars still raises the short-description error."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("Valid title", "short")
|
||||
assert any("Description" in e for e in errors)
|
||||
|
||||
|
||||
# --- AC-7: backward compatibility ------------------------------------------
|
||||
|
||||
def test_tc10_backward_compat_titles_81_to_200(monkeypatch):
|
||||
"""TC-10: a title previously rejected by the 80-char cap now passes at 200."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("x" * 100, VALID_DESCRIPTION)
|
||||
assert _title_length_error(errors) is None
|
||||
@@ -58,6 +58,12 @@ def lease_spy(monkeypatch):
|
||||
# Default merge_gate scope: real for the self-hosting orchestrator repo.
|
||||
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
|
||||
monkeypatch.setattr(qg.settings, "merge_gate_repos", "")
|
||||
# ORCH-026: these ORCH-043 composition tests assert the ancestor-based
|
||||
# short-circuit ("branch up-to-date with main" -> no rebase). That is now the
|
||||
# `premerge_rebase_always=False` kill-switch path; pin it OFF here so they
|
||||
# keep testing the legacy ORCH-043 behaviour. The new always-rebase default
|
||||
# (True) is covered by tests/test_orch026_premerge_rebase.py (TC-A01).
|
||||
monkeypatch.setattr(qg.settings, "premerge_rebase_always", False, raising=False)
|
||||
return state
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user