diff --git a/.env.example b/.env.example index 55cd373..6c5b037 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,22 @@ 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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 531cd63..8b1459c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Управление зависимостями задач (B ждёт A) + сериализация мержа одного репо** (ORCH-026): два уровня по ADR-001, оба условны (kill-switch + CSV-область, never-raise), без новой стадии и без изменения `STAGE_TRANSITIONS`/реестра `QG_CHECKS`. **Уровень A — сериализация merge/deploy внутри одного репо:** переиспользует существующий merge-lease ORCH-043/065 (никакого нового механизма); единственная новая логика — **безусловный pre-merge rebase**: в `check_branch_mergeable` (`src/qg/checks.py`) под удержанным лизом при флаге `premerge_rebase_always` (дефолт `True`) `auto_rebase_onto_main` вызывается **всегда** (а не только при `branch_is_behind_main`) — детерминированный структурный анти-фантом на ребре планировщика, дополняющий рубежи ORCH-073. На актуальной ветке это no-op (rebase не сдвигает HEAD, `push --force-with-lease` → «Everything up-to-date», CI не триггерится); kill-switch `premerge_rebase_always=False` → прежнее поведение ORCH-043 1:1. Окно сериализации «merge → main-updated» per-repo (для self `done` ⇔ SHA-in-main, ORCH-073): пока A не в `main`, B того же репо получает `merge-lock busy` → defer (не откат); кросс-репо параллелизм сохранён (лиз — per-repo файл). **Уровень B — декларативные зависимости задач:** аддитивная таблица `job_deps(task_id, depends_on_task_id)` (идемпотентный `CREATE TABLE/INDEX IF NOT EXISTS` в `init_db`, без миграции на живой БД); гейт планировщика в `claim_next_job` (`src/db.py`) — `NOT EXISTS (job_deps d JOIN tasks t … WHERE d.task_id=jobs.task_id AND t.stage!='done')` при `task_deps_enabled`: задача с незавершённой зависимостью **не выбирается** и слот `max_concurrency` не занимает; инертно при пустой `job_deps` → нулевая регрессия, kill-switch `task_deps_enabled=False` → запрос 1:1 как ORCH-1. Новый leaf-модуль `src/task_deps.py` (контракт never-raise): `is_task_ready` (fail-open → ready), DFS-детектор циклов (`detect_cycle`/`find_any_cycle`, итеративный WHITE/GREY/BLACK), `handle_cycle` (`set_issue_blocked` по каждой задаче цикла + один Telegram-alert с цепочкой «A → B → A»), `declare_dependency` (вставка + детект цикла), `ingest_plane_relations` (только для `task_deps_source=plane|hybrid`: резолв Plane `blocked-by` UUID → локальный task → запись в `job_deps`; источник истины горячего цикла остаётся БД, дефолт `db` НЕ ходит в сеть на claim), `snapshot` (read-only сводка). Видимость: строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (`src/notifications.py`, never-raise, инвариант «одна карточка на задачу» сохранён); блок `task_deps` в `GET /queue` (`src/main.py`). Совместимость: `reconciler` F-1 пропускает dep-заблокированные задачи (`is_task_ready`, паттерн ORCH-060) + backstop-детект цикла; `job_reaper` сканирует только `running` → dep-блок остаётся `queued`. Зависимости — только intra-repo (v1). Новые настройки: `ORCH_PREMERGE_REBASE_ALWAYS` (true), `ORCH_TASK_DEPS_ENABLED` (true), `ORCH_TASK_DEPS_SOURCE` (db). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (гейт зависимостей — врезка в `claim_next_job`, НЕ зарегистрированный QG), схема `tasks`/`jobs`/`agent_runs`, внешние HTTP-эндпоинты; non-self (enduro) — no-op при пустых `job_deps`/области. ADR `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`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_orch026_premerge_rebase.py`, `tests/test_orch026_merge_serialize.py`, `tests/test_orch026_conditionality.py`, `tests/test_orch026_task_deps.py`, `tests/test_orch026_dep_cycles.py`, `tests/test_orch026_dep_visibility.py`, `tests/test_orch026_migration.py`, `tests/test_orch026_queue_observability.py`, `tests/test_orch026_serialize_integration.py`, `tests/test_orch026_deps_integration.py`. - **CRIT: системный фикс эрозии `main` — SHA-в-main как единственный критерий merge-verify + регресс-гард + `.gitattributes`** (ORCH-073): устранён корень фантомного merge, из-за которого код задач ORCH-067 (`plane_issue_link`) и ORCH-069 (`qg0_title_max`) дошёл до `done`, но физически отсутствовал в `origin/main` (в `main` попадали только их авто docs-PR). **(FR-1)** `merge_gate.verify_merged_to_main` подтверждает merge **ТОЛЬКО** прямым фактом `git merge-base --is-ancestor origin/main` — OR-ветка `pr_already_merged` удалена (merged PR больше не подтверждает merge); пустой SHA / git-ошибка → `False` (fail-closed, never-raise). **(FR-2)** `pr_already_merged` понижен до idempotency-guard для `merge_pr` и засчитывает PR лишь при `merged & head.ref== & base.ref=="main"` (явный in-loop фильтр вместо ненадёжного query-параметра `head` — исключает авто docs-PR). **(FR-3)** `merge_pr` выбирает open code-PR строго по `head.ref==` И `base.ref=="main"`; merge только через Gitea PR-merge API, никогда push/force-push в `main`. **(FR-5)** новый детерминированный регресс-гард `merge_gate.check_main_regression` в `_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done` проверяет, что `origin/main` содержит декларативный append-only набор маркеров ранее-merged задач (`MAIN_REGRESSION_MARKERS`, `git grep -c origin/main -- `); детерминированный `count==0` → alert «main regressed» + HOLD (`set_issue_blocked` + Telegram + Plane, задача НЕ `done`, БЕЗ авто-отката на `development`), git-ошибка самого грепа → fail-OPEN (не блокирует, SHA-в-main остаётся первичным гейтом). Kill-switch `ORCH_REGRESSION_GUARD_ENABLED` (дефолт `true`), область — `merge_verify_applies` (self-hosting / `merge_verify_repos`), non-self → no-op. **(FR-4)** корневой `.gitattributes` с `CHANGELOG.md merge=union` — правки `## [Unreleased]` авто-сливаются при `auto_rebase_onto_main` без конфликта (обе записи сохраняются), ветка не откатывается в `development` и не тащит устаревший код-сосед; `docs/**` под union НЕ ставится. `GET /queue::merge_verify_status` дополнен счётчиком `main_regressed_alerts_total` (read-only). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`), `check_deploy_status`/`_parse_deploy_status`, merge-gate, image-freshness, схема БД, внешние HTTP-эндпоинты; non-self (enduro) merge/verify/гард — no-op (INV-5); ручной `Confirm Deploy` сохранён. ADR `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md` (+ сквозной `adr-0014`). Документация: `docs/architecture/README.md`, `.env.example`. Тесты: `tests/test_orch073_*.py` (TC-01..18). - **Конфигурируемый верхний лимит длины заголовка QG-0 (`ORCH_QG0_TITLE_MAX`, дефолт 200)** (ORCH-069): хардкод `if len(name) > 80` во входной валидации `_qg0_errors` (`src/webhooks/plane.py`) вынесен в настраиваемый параметр `Settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). Лимит 80 был гигиеническим, а не структурным (slug режется независимо `[:30]`, `tasks.title TEXT` без ограничения), поэтому валидные заголовки 81–200 символов отклонялись на входе без бизнес-причины. Лимит читается из `settings.qg0_title_max` динамически на каждый вызов (тесты патчат значение), текст ошибки подставляет актуальное число; граница строгая (`len > limit` → FAIL, `len == limit` → PASS). **Graceful-деградация (AC-3, self-hosting safety):** пустое/нечисловое значение env не роняет процесс на старте — `field_validator(mode="before")` `_qg0_title_max_default` в `src/config.py` перехватывает сырое env ДО `int`-парсинга pydantic и при невалидном/пустом входе возвращает дефолт 200 (never-raise), гася `ValidationError`. Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят (AC-7). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (QG-0 — inline-валидация входа, не зарегистрированный stage-gate), схема БД, slug-логика `[:30]`, нижние лимиты (`< 5` title, `< 20` description), soft-QG-0 поведение (warning на `work_item.created`), API. ADR `docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md`. Документация: `.env.example`, `.env.staging.example`. Тесты: `tests/test_qg0_title_limit.py`. diff --git a/CLAUDE.md b/CLAUDE.md index 7b3b780..dcbdd29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 89b6955..4d5cf3a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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/.merge-lease-.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** на репо (`/.merge-lease-.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`) стадия @@ -452,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/`. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index af5d1b2..e496903 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -20,11 +20,12 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | 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 использовать следующий -> свободный номер (текущий максимум — `0014`). +> свободный номер (текущий максимум — `0015`). > adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»). ## Формат diff --git a/docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md b/docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md new file mode 100644 index 0000000..7e2e7ae --- /dev/null +++ b/docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.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`, без внеочередного +рестарта прода. diff --git a/docs/work-items/ORCH-026/00-business-request.md b/docs/work-items/ORCH-026/00-business-request.md new file mode 100644 index 0000000..6109cb4 --- /dev/null +++ b/docs/work-items/ORCH-026/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Управление зависимостями задач (B ждёт A) в очереди + +Work Item ID: ORCH-026 + +## Description + +TBD diff --git a/docs/work-items/ORCH-026/01-brd.md b/docs/work-items/ORCH-026/01-brd.md new file mode 100644 index 0000000..2a3e99a --- /dev/null +++ b/docs/work-items/ORCH-026/01-brd.md @@ -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-.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`. diff --git a/docs/work-items/ORCH-026/02-trz.md b/docs/work-items/ORCH-026/02-trz.md new file mode 100644 index 0000000..8ca41e3 --- /dev/null +++ b/docs/work-items/ORCH-026/02-trz.md @@ -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-.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`. diff --git a/docs/work-items/ORCH-026/03-acceptance-criteria.md b/docs/work-items/ORCH-026/03-acceptance-criteria.md new file mode 100644 index 0000000..7acda59 --- /dev/null +++ b/docs/work-items/ORCH-026/03-acceptance-criteria.md @@ -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 или регресс существующих тестов. diff --git a/docs/work-items/ORCH-026/04-test-plan.yaml b/docs/work-items/ORCH-026/04-test-plan.yaml new file mode 100644 index 0000000..9da9e73 --- /dev/null +++ b/docs/work-items/ORCH-026/04-test-plan.yaml @@ -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 diff --git a/docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md b/docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md new file mode 100644 index 0000000..58e3304 --- /dev/null +++ b/docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md @@ -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-.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`. diff --git a/docs/work-items/ORCH-026/08-data-requirements.md b/docs/work-items/ORCH-026/08-data-requirements.md new file mode 100644 index 0000000..c20004c --- /dev/null +++ b/docs/work-items/ORCH-026/08-data-requirements.md @@ -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-.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 — файловый, вне БД. diff --git a/docs/work-items/ORCH-026/10-tech-risks.md b/docs/work-items/ORCH-026/10-tech-risks.md new file mode 100644 index 0000000..8b35485 --- /dev/null +++ b/docs/work-items/ORCH-026/10-tech-risks.md @@ -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). | diff --git a/docs/work-items/ORCH-026/12-review.md b/docs/work-items/ORCH-026/12-review.md new file mode 100644 index 0000000..7ac47ea --- /dev/null +++ b/docs/work-items/ORCH-026/12-review.md @@ -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: требование выполнено. diff --git a/docs/work-items/ORCH-026/13-test-report.md b/docs/work-items/ORCH-026/13-test-report.md new file mode 100644 index 0000000..64d0d4f --- /dev/null +++ b/docs/work-items/ORCH-026/13-test-report.md @@ -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`. diff --git a/docs/work-items/ORCH-026/14-deploy-log.md b/docs/work-items/ORCH-026/14-deploy-log.md new file mode 100644 index 0000000..3740311 --- /dev/null +++ b/docs/work-items/ORCH-026/14-deploy-log.md @@ -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. diff --git a/src/config.py b/src/config.py index 65e078e..dc93615 100644 --- a/src/config.py +++ b/src/config.py @@ -396,6 +396,37 @@ 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 diff --git a/src/db.py b/src/db.py index bbe0e5b..579ec04 100644 --- a/src/db.py +++ b/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 # --------------------------------------------------------------------------- diff --git a/src/main.py b/src/main.py index cc23797..a602b24 100644 --- a/src/main.py +++ b/src/main.py @@ -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), } diff --git a/src/notifications.py b/src/notifications.py index a688fd1..1af7dad 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -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"], diff --git a/src/plane_sync.py b/src/plane_sync.py index ca2ad62..f2e31fb 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -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 diff --git a/src/qg/checks.py b/src/qg/checks.py index 2c95d84..78db3c4 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -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" diff --git a/src/reconciler.py b/src/reconciler.py index 5ae330e..a442f54 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -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, diff --git a/src/task_deps.py b/src/task_deps.py new file mode 100644 index 0000000..97c1353 --- /dev/null +++ b/src/task_deps.py @@ -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, + } diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 4bdaf0c..c56bbbd 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -608,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. diff --git a/tests/test_merge_gate_race.py b/tests/test_merge_gate_race.py index 8a885f6..398909f 100644 --- a/tests/test_merge_gate_race.py +++ b/tests/test_merge_gate_race.py @@ -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) diff --git a/tests/test_orch026_conditionality.py b/tests/test_orch026_conditionality.py new file mode 100644 index 0000000..d97f328 --- /dev/null +++ b/tests/test_orch026_conditionality.py @@ -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 diff --git a/tests/test_orch026_dep_cycles.py b/tests/test_orch026_dep_cycles.py new file mode 100644 index 0000000..7254b39 --- /dev/null +++ b/tests/test_orch026_dep_cycles.py @@ -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 diff --git a/tests/test_orch026_dep_visibility.py b/tests/test_orch026_dep_visibility.py new file mode 100644 index 0000000..3b3c1a0 --- /dev/null +++ b/tests/test_orch026_dep_visibility.py @@ -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 diff --git a/tests/test_orch026_deps_integration.py b/tests/test_orch026_deps_integration.py new file mode 100644 index 0000000..95c63f5 --- /dev/null +++ b/tests/test_orch026_deps_integration.py @@ -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 diff --git a/tests/test_orch026_merge_serialize.py b/tests/test_orch026_merge_serialize.py new file mode 100644 index 0000000..87a54b9 --- /dev/null +++ b/tests/test_orch026_merge_serialize.py @@ -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 diff --git a/tests/test_orch026_migration.py b/tests/test_orch026_migration.py new file mode 100644 index 0000000..606ac2f --- /dev/null +++ b/tests/test_orch026_migration.py @@ -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() diff --git a/tests/test_orch026_premerge_rebase.py b/tests/test_orch026_premerge_rebase.py new file mode 100644 index 0000000..d257c75 --- /dev/null +++ b/tests/test_orch026_premerge_rebase.py @@ -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 diff --git a/tests/test_orch026_queue_observability.py b/tests/test_orch026_queue_observability.py new file mode 100644 index 0000000..bb204f5 --- /dev/null +++ b/tests/test_orch026_queue_observability.py @@ -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"] diff --git a/tests/test_orch026_serialize_integration.py b/tests/test_orch026_serialize_integration.py new file mode 100644 index 0000000..7fdcde1 --- /dev/null +++ b/tests/test_orch026_serialize_integration.py @@ -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)" diff --git a/tests/test_orch026_task_deps.py b/tests/test_orch026_task_deps.py new file mode 100644 index 0000000..d9d9146 --- /dev/null +++ b/tests/test_orch026_task_deps.py @@ -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)" diff --git a/tests/test_qg_merge_gate.py b/tests/test_qg_merge_gate.py index 302f012..98b7dbe 100644 --- a/tests/test_qg_merge_gate.py +++ b/tests/test_qg_merge_gate.py @@ -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