Compare commits

...

6 Commits

Author SHA1 Message Date
30d9f8e5ab tester(ET): auto-commit from tester run_id=390
All checks were successful
CI / test (push) Successful in 25s
CI / test (pull_request) Successful in 23s
2026-06-08 19:13:00 +03:00
aaa48299e8 reviewer(ET): auto-commit from reviewer run_id=389
All checks were successful
CI / test (push) Successful in 24s
CI / test (pull_request) Successful in 29s
2026-06-08 19:10:56 +03:00
4449cc3dd0 feat(ORCH-026): task dependencies (B waits for A) + single-repo merge serialization
All checks were successful
CI / test (push) Successful in 24s
CI / test (pull_request) Successful in 24s
Level A — merge/deploy serialization within one repo: reuse the existing
ORCH-043/065 merge-lease (no new mechanism); the only new logic is an
unconditional pre-merge rebase in check_branch_mergeable — under the held
lease, auto_rebase_onto_main is ALWAYS called when premerge_rebase_always
(default True), not just when the branch is behind. No-op on an up-to-date
branch (rebase keeps HEAD, force-with-lease -> "Everything up-to-date", CI
not triggered). Kill-switch off -> ORCH-043 behaviour 1:1.

Level B — declarative task dependencies: additive job_deps table
(CREATE ... IF NOT EXISTS, no live-DB migration); claim_next_job gate
(NOT EXISTS) defers a job whose depends-on tasks are not yet 'done' without
occupying a max_concurrency slot; inert on empty job_deps -> zero regression.
New leaf src/task_deps.py (never-raise): is_task_ready (fail-open), DFS cycle
detection + Blocked/alert, declare/ingest_plane_relations (db source never
hits the network on the hot path), snapshot. Telegram waiting-line, /queue
observability, reconciler skip + cycle backstop, reaper untouched.

Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry (dep gate is a
claim_next_job врезка, not a registered QG), DB schema of existing tables,
HTTP endpoints; non-self repos remain a no-op on empty deps/scope.

Flags: ORCH_PREMERGE_REBASE_ALWAYS, ORCH_TASK_DEPS_ENABLED, ORCH_TASK_DEPS_SOURCE.
Docs: docs/architecture/README.md, CLAUDE.md, .env.example, CHANGELOG.md,
adr-0015. Tests: tests/test_orch026_*.py (64 tests); full suite 991 green.

Refs: ORCH-026

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:06:22 +03:00
f8ec1c2f6e architect(ET): auto-commit from architect run_id=387
All checks were successful
CI / test (push) Successful in 22s
2026-06-08 18:43:57 +03:00
64d7422f2e analyst(ET): auto-commit from analyst run_id=386
All checks were successful
CI / test (push) Successful in 21s
2026-06-08 18:32:55 +03:00
cf6c7ac23b docs: init ORCH-026 business request
All checks were successful
CI / test (push) Successful in 21s
2026-06-08 18:28:11 +03:00
37 changed files with 2733 additions and 5 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -10,7 +10,7 @@
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). Все алерты, упоминающие `work_item_id`, делают номер кликабельным. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7.
@@ -59,11 +59,24 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием:
- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`.
- **Безусловный pre-merge rebase (ORCH-026, A-2):** при `premerge_rebase_always` (дефолт `True`, скоуп `merge_gate_repos`) short-circuit `branch_is_behind_main` пропускается — `auto_rebase_onto_main` вызывается **всегда** под лизом. На актуальной ветке это no-op (`rebase` не меняет HEAD, `push --force-with-lease` → «Everything up-to-date», CI не триггерится); на отстающей — реальный догон. Детерминированный структурный анти-фантом на уровне планировщика (дополняет рубежи ORCH-073, не заменяет). Kill-switch `premerge_rebase_always=False` → прежнее поведение (ребейз только при behind).
- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД. **ORCH-026 (A-1):** это окно = «merge → main-updated» (для self `done` ⇔ SHA-in-main, ORCH-073) — пока A не в `main`, B того же репо получает `merge-lock busy` → defer. Окно сериализации per-repo НЕ переписывается; кросс-репо параллелизм сохранён (лиз — per-repo файл).
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**.
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
Безусловный pre-merge rebase + связь с зависимостями задач — [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md) (ORCH-026).
### Зависимости задач: B ждёт A (ORCH-026, Уровень B)
Плоская очередь ORCH-1 (FIFO по `id` + `available_at` + `max_concurrency`) не выражала логических зависимостей. ORCH-026 вводит декларативные связи «задача B не стартует, пока не готовы её depends-on» — без новой стадии и без изменения `STAGE_TRANSITIONS`/`QG_CHECKS`.
- **Источник истины планировщика — БД** (аддитивная таблица `job_deps(task_id, depends_on_task_id)`): claim в горячем цикле обслуживает очередь ВСЕХ проектов и обязан быть offline-устойчив (сетевой Plane на каждый claim = встанет очередь всех проектов). Источник **декларации** настраивается `task_deps_source = db|plane|hybrid` (дефолт `db`; `plane`/`hybrid` читают Plane relations в `handle_work_item_created` и кэшируют в `job_deps`).
- **Гейт планировщика (`claim_next_job`)** — условие `NOT EXISTS (job_deps d JOIN tasks t … WHERE d.task_id=j.task_id AND t.stage!='done')` при `task_deps_enabled`: задача с незавершённой зависимостью **не выбирается** (агент не запускается, слот `max_concurrency` не занимается). Инертно при пустой `job_deps` → нулевая регрессия; kill-switch `task_deps_enabled=False` → запрос 1:1 как ORCH-1.
- **Детект дедлоков** — DFS-цикл-детектор (leaf `src/task_deps.py::detect_cycle`) при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert (Telegram/Plane) с перечислением цикла. Поток остальных задач не блокируется.
- **Видимость** — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (`update_task_tracker`, never-raise); Plane `Blocked` — на дедлоке (не на нормальном коротком ожидании, чтобы не флаппить). Инвариант «одна карточка на задачу» сохранён.
- **Совместимость:** `reconciler` F-1 пропускает dep-заблокированные задачи (`is_task_ready`, паттерн ORCH-060); `reaper` сканирует только `running` → dep-блок остаётся `queued`, не трогается. Зависимости — только intra-repo (v1).
- **Наблюдаемость:** блок `task_deps` в `GET /queue` (заблокированные задачи, держатель merge-lease, defer-счётчики, обнаруженные циклы) — read-only.
Подробнее: [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md), детально — `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
@@ -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/<project>`.

View File

@@ -20,11 +20,12 @@ Per-work-item решения живут в `docs/work-items/<id>/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»).
## Формат

View File

@@ -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`, без внеочередного
рестарта прода.

View File

@@ -0,0 +1,7 @@
# Business Request: Управление зависимостями задач (B ждёт A) в очереди
Work Item ID: ORCH-026
## Description
TBD

View File

@@ -0,0 +1,135 @@
# 01-BRD — Управление зависимостями задач (B ждёт A) в очереди
**Work Item:** ORCH-026
**Repo:** orchestrator (self-hosting)
**Branch:** feature/ORCH-026-b-a
**Стадия:** analysis
**Источник:** предложение Стрим, одобрено Славой (2026-06-04); дополнение Слава+Стрим 2026-06-08 (инцидент эрозии `main`)
---
## 1. Контекст и проблема
### 1.1 Первопричина (мотивация СЕЙЧАС — инцидент 08.06)
Эрозия `main` 08.06 (потеря кода ORCH-067/069, фантом-merge) родилась НЕ из логических
зависимостей, а из **некоординированного параллелизма**: несколько self-hosting задач
(ORCH-067/069/071) одновременно срезали ветки от `main` и правили общие файлы
(`CHANGELOG.md`, `notifications.py`, `config.py`). Последствия:
- CHANGELOG-конфликты на `auto_rebase` → откаты `deploy-staging → development` (дорого:
ORCH-069 = 3 попытки = $3.98);
- тихое затирание кода соседа при merge ветки, срезанной от устаревшего `main` (фантом).
**ORCH-073** закрыл ПОСЛЕДСТВИЯ (3 рубежа: CHANGELOG `merge=union` + SHA-in-main verify +
регресс-гард маркеров). ORCH-026 должен закрыть **ПЕРВОПРИЧИНУ**: задачи одного репо не
должны мешать друг другу в `main`.
### 1.2 Исходный скоуп (плоская очередь ORCH-1)
Очередь (`src/queue_worker.py`, ORCH-1) — плоская: `jobs` упорядочены по `id` (FIFO),
гейтятся только `available_at` и `max_concurrency`. Нельзя выразить «задача B не стартует,
пока не готова A». Декомпозиция эпиков (ORCH-025) порождает заведомо зависимые подзадачи.
### 1.3 Что уже есть (опора, НЕ переписывать)
- **ORCH-1** — персистентная очередь (`jobs`), atomic claim, `available_at`-defer, restart-safe.
- **ORCH-065** — `merge-lease` (`src/merge_gate.py`): per-repo файловый лиз
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный
реклейм мёртвого/устаревшего держателя. **Сейчас лиз держится только на ребре
`deploy-staging → deploy`** (от merge-gate до фактического merge).
- **ORCH-043** — merge-gate: `branch_is_behind_main`, `auto_rebase_onto_main` (rebase
**только когда ветка отстаёт или при конфликте**), `retest_branch`.
- **ORCH-073** — merge-verify: `verify_merged_to_main` (SHA-in-main), `check_main_regression`.
- **Plane-статусы** `Blocked` / `Needs Input` + `set_issue_blocked` (`src/plane_sync.py`).
- **Telegram live-tracker** (`src/notifications.py`) — одна карточка на задачу, уже умеет
показывать статус `Blocked`.
---
## 2. Цель (бизнес-результат)
Задачи одного репозитория перестают повреждать `main` друг друга, а очередь умеет
выражать логические зависимости между задачами — БЕЗ потери параллелизма между разными
репозиториями и без риска для self-hosting прода.
---
## 3. Два уровня требований (объединить в одной задаче; приоритет — Уровень A)
### Уровень A — Сериализация merge/деплоя внутри ОДНОГО репо (КРИТИЧНО, корень эрозии)
Закрывает первопричину инцидента 08.06.
- **A-1.** В рамках ОДНОГО репо merge-в-`main` + деплой должны быть **сериализованы**: пока
задача A не слита в `main` (и для self-hosting — не задеплоена), задача B того же репо НЕ
доходит до своего merge/деплоя от устаревшего `main`.
- **A-2.** B перед своим merge-gate **обязана ребейзнуться на СВЕЖИЙ `main`** (где уже есть
A) — **proactive pre-merge rebase**, а не только при текстовом конфликте (как сейчас в
ORCH-043). Цель: B всегда несёт актуальный код предшественников → структурный анти-фантом
на уровне планировщика (дополняет рубежи ORCH-073, не заменяет).
- **A-3.** Сериализация — **только внутри одного репо**. Задачи РАЗНЫХ репо (orchestrator vs
enduro-trails) параллелятся свободно (общая БД/очередь — пропускная способность не падает).
- **A-4.** Механизм — минимально-инвазивный и **restart-safe** (как ORCH-1/065): переживает
рестарт прод-контейнера, не оставляет навсегда захваченных ресурсов (опора на проактивный
реклейм ORCH-065).
- **A-5.** **Совместимость с self-hosting safety:** не ронять/не рестартить прод-контейнер
вне штатного deploy; гейт `Confirm Deploy` (ORCH-059) сохранён; никаких push/force-push в
`main`.
- **A-6.** Защита от взаимоблокировки: B при занятой сериализации **defer** (повторная
постановка с задержкой через `available_at`), а НЕ откат на `development` и НЕ вечное
ожидание; bounded defer-бюджет (анти-livelock, как `merge_defer_max_attempts`).
### Уровень B — Декларативные зависимости (исходный скоуп ORCH-26)
- **B-1.** Задача может объявить связь `blocked-by` / `blocks` (depends-on).
- **B-2.** Планировщик очереди (ORCH-1) **не запускает** заблокированную задачу, пока все её
depends-on не достигли терминального состояния (`done`).
- **B-3.** **Защита от дедлоков:** циклические зависимости детектируются; задача в цикле не
«пропадает молча» — выставляется `Blocked` + alert (Telegram/Plane).
- **B-4.** **Видимость:** заблокированная задача видна — Plane-статус `Blocked` и/или
ожидание в Telegram-карточке (что и кого ждёт).
---
## 4. Открытые вопросы для архитектора (НЕ решаются на этапе анализа)
> Аналитик фиксирует требования; выбор механизма — за архитектором (ADR в `06-adr/`).
1. **Где хранить связи (Уровень B):** Plane relations (родное, видимо в UI, но требует
сетевого запроса и зависит от Plane) vs таблица в БД (`job_deps`/поля `tasks`, надёжно и
offline, но дубль источника) vs **гибрид** (Plane — источник декларации, БД — кэш для
планировщика). Рекомендация анализа: гибрид с offline-fallback (см. §6).
2. **Механизм сериализации (Уровень A):** глобальный per-repo merge-lock vs FIFO merge-queue
vs **обязательный pre-merge rebase + расширение окна merge-lease** (от «момента merge» до
«main-updated»). Выбрать минимально-инвазивный, restart-safe, переиспользующий ORCH-065/043.
3. **Граница окна сериализации для self-hosting:** для не-self репо «merged в main» = конец
окна; для self (orchestrator) деплой асинхронный (Phase B/C, ORCH-036/071) — нужно решить,
до какого события держать лиз (до `merged_to_main: true` / до `done`).
4. **Совместимость B и A:** depends-on (B) на уровне постановки в очередь vs merge-сериализация
(A) на уровне merge-gate — разные точки конвейера; убедиться, что не конфликтуют.
---
## 5. Вне скоупа (Non-goals)
- Изменение машины стадий `STAGE_TRANSITIONS` (сериализация/зависимости — врезки/гейты, не
новые стадии — паттерн ORCH-043/058/071).
- Приоритизация/перепланирование задач по весам (только зависимости и сериализация).
- Кросс-репо зависимости (A-3 явно запрещает кросс-репо сериализацию; кросс-репо логические
зависимости — возможный follow-up, не v1).
- Отмена/замена рубежей ORCH-073 — ORCH-026 их **дополняет** на уровне планировщика.
---
## 6. Заинтересованные стороны
- **Owner (Слава)** — одобряет BRD; держатель self-hosting прод-риска.
- **Стрим** — автор предложения.
- **Конвейер агентов** — потребитель: developer/deployer работают с веткой, которую затрагивает
сериализация; reviewer проверяет обновление доки.
---
## 7. Критерии успеха (бизнес-уровень)
- Две зелёные задачи одного репо больше не способны затереть код друг друга в `main` на уровне
планировщика (без участия рубежей-последствий ORCH-073).
- Задача может объявить зависимость; заблокированная задача не стартует раньше времени и видна
наблюдателю.
- Пропускная способность разных репо не деградирует.
- Прод-контейнер orchestrator не падает и не рестартится вне штатного `Confirm Deploy`.
Точные PASS/FAIL — `03-acceptance-criteria.md`.

View File

@@ -0,0 +1,134 @@
# 02-ТЗ — Управление зависимостями задач (B ждёт A) в очереди
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
> ТЗ фиксирует ТРЕБОВАНИЯ к изменениям (модули, контракты, артефакты). Конкретный механизм
> сериализации и место хранения связей — решение архитектора (ADR в `06-adr/`); ниже отмечены
> как «КАНДИДАТ / решает архитектор». Аналитик не предлагает архитектуру.
---
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче | Уровень |
|--------|---------------|---------|
| `src/queue_worker.py` | Планировщик: `_drain_once` / `claim_next_job` — точка учёта зависимостей и сериализации при выборе job. | A + B |
| `src/db.py` | Очередь `jobs` / `tasks`; `claim_next_job`, `enqueue_job`, `count_running_jobs`. Кандидат на хранение связей и блокировки claim. | A + B |
| `src/merge_gate.py` | merge-lease (ORCH-065), `branch_is_behind_main` / `auto_rebase_onto_main` (ORCH-043) — опора для proactive pre-merge rebase и расширения окна сериализации. | A |
| `src/qg/checks.py` | `check_branch_mergeable` (под-гейт ребра `deploy-staging → deploy`) — точка форсированного pre-merge rebase. | A |
| `src/stage_engine.py` | `advance_stage` — врезки гейтов; точка интеграции сериализации/верификации. | A |
| `src/webhooks/plane.py` | `handle_work_item_created` / `start_pipeline` — приём задачи; точка чтения relations (если источник — Plane). | B |
| `src/plane_sync.py` | `set_issue_blocked`, `get_project_states` (`blocked`/`needs_input`), relations API. | B |
| `src/notifications.py` | live-карточка: индикация `Blocked` / «ждёт ORCH-NNN». | B |
| `src/config.py` | Новые kill-switch + scope-настройки (паттерн `*_enabled` / `*_repos`). | A + B |
| `src/reconciler.py` / `src/job_reaper.py` | Не ломать: skip заблокированных задач (как уже делается для Blocked/Needs-Input, ORCH-060/068); реклейм ресурсов сериализации. | A + B |
---
## 2. Требования к изменениям — Уровень A (сериализация merge/деплоя)
### 2.1 Proactive pre-merge rebase (A-2)
- На ребре `deploy-staging → deploy`, ДО фактического merge (в составе `check_branch_mergeable`
или соседнего под-гейта), ветка задачи **всегда** догоняется на свежий `origin/main`
**не только при `branch_is_behind_main`/конфликте**.
- Переиспользовать `merge_gate.auto_rebase_onto_main` (rebase + `push --force-with-lease`
ТОЛЬКО ветки задачи). Текстовый конфликт → существующий контракт: `rebase --abort` → откат на
`development` (как ORCH-043).
- **Инвариант:** никаких push/force-push в `main`.
### 2.2 Расширение окна merge-lease (A-1, A-3, A-4)
- **КАНДИДАТ (решает архитектор):** держать per-repo merge-lease (ORCH-065) не только «на
момент merge», а на окно **«merge → main-updated»** (для self — до подтверждения
`merged_to_main: true` / `done`), чтобы B не дошла до своего merge, пока A не в `main`.
- Acquire — **неблокирующий** (как сейчас): занято → **defer** задачи B через
`enqueue_job(available_at_delay_s=...)`, bounded бюджет (анти-livelock; ср.
`merge_defer_max_attempts`). Откат на `development` НЕ применять для defer.
- Release — holder-aware (как `release_merge_lease`), на merged-вебхуке / `deploy→done` /
откате / по проактивному реклейму (ORCH-065 `reclaim_stale_lease`).
- Сериализация **строго per-repo** (`.merge-lease-<repo>.json`) — кросс-репо параллелизм не
затрагивается (A-3).
### 2.3 Условность и безопасность (A-5)
- Реально только для применимых репо: kill-switch + CSV-scope (паттерн `merge_gate_repos` /
`merge_verify_repos`; пусто → только self-hosting `orchestrator`).
- `STAGE_TRANSITIONS`, `Confirm Deploy` (ORCH-059), exit-коды deploy-хука, БАГ-8,
terminal-sync — **без изменений**.
- Контракт **never-raise** для всех новых функций (как соседи в `merge_gate.py`).
---
## 3. Требования к изменениям — Уровень B (декларативные зависимости)
### 3.1 Декларация связи (B-1)
- **КАНДИДАТ хранения (решает архитектор, см. BRD §4.1):**
- вариант Plane relations: читать `blocked-by` через Plane API в `handle_work_item_created`;
- вариант БД: новая таблица `job_deps(task_id, depends_on_task_id)` или поле в `tasks`
(idempotent `_ensure_column` миграция, как ORCH-065 `jobs.pid`);
- гибрид: Plane — декларация, БД — кэш для планировщика (offline-устойчивость).
- Миграция БД (если выбран вариант с таблицей/колонкой) — **только аддитивная**
(`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), безопасная на живой прод-БД с общими
данными enduro-trails.
### 3.2 Гейт планировщика (B-2)
- При выборе job (`claim_next_job` / `_drain_once`) задача с незавершёнными depends-on
**не клеймится** (аналог `available_at`-gate): пропускается до тех пор, пока все depends-on
не `done`. Не должна занимать слот `max_concurrency`.
- Реализация — **leaf-функция** с чистой логикой «готова ли задача к запуску» (тестируемо
юнитами, never-raise), по образцу `staging_verdict.py` / `post_deploy.py`.
### 3.3 Защита от дедлоков (B-3)
- Детектор циклов в графе depends-on (DFS/обнаружение цикла) — чистая функция, юнит-тестируемая.
- Цикл → задача(и) НЕ запускается молча: `set_issue_blocked` + alert (Telegram/Plane) с
указанием цикла. Не блокировать поток других задач.
### 3.4 Видимость (B-4)
- Заблокированная задача: Plane-статус `Blocked` (`set_issue_blocked`) и/или строка ожидания в
Telegram-карточке («⏳ ждёт ORCH-NNN»). Использовать существующий механизм карточки
(`notifications.update_task_tracker`), контракт never-raise / silent.
- `reconciler` F-1 уже пропускает Blocked/Needs-Input (ORCH-060/068) — убедиться, что новые
заблокированные-по-зависимости задачи тоже пропускаются (не «разблокируются» ошибочно).
---
## 4. Изменения API (endpoints)
- **Новые HTTP endpoints не требуются.**
- **Наблюдаемость:** расширить снимок `GET /queue` блоком о зависимостях/сериализации
(по образцу блоков `reconcile` / `reaper` / `post_deploy` / `merge_verify`): кол-во
заблокированных задач, держатель merge-lease, defer-счётчики, обнаруженные циклы. Read-only,
никогда не источник истины для решений.
## 5. Изменения схемы БД
- **КАНДИДАТ (если выбран БД/гибрид для Уровня B):** аддитивная таблица `job_deps` или колонка
в `tasks` (см. §3.1). Только `CREATE TABLE IF NOT EXISTS` / `_ensure_column`. Без изменения
существующих колонок `jobs`/`tasks`. Restart-safe, безопасно на общей прод-БД.
- Уровень A (сериализация) — **без изменения схемы БД** (merge-lease файловый, как ORCH-065).
## 6. Требования к новым QG checks
- **Новый зарегистрированный QG-чек НЕ вводится** (паттерн ORCH-071/058: под-гейт — врезка в
`advance_stage` или расширение `check_branch_mergeable`, а не новая запись в `QG_CHECKS`).
- Реестр `QG_CHECKS` — без изменений.
## 7. Конфигурация (`src/config.py`)
Новые настройки по паттерну `*_enabled` (kill-switch) + `*_repos` (CSV scope, пусто →
self-hosting). КАНДИДАТ-имена (финализирует архитектор):
- Уровень A: `merge_serialize_enabled` / `merge_serialize_repos` (или расширение
`merge_gate_*`); опционально `premerge_rebase_always` (вкл proactive rebase).
- Уровень B: `task_deps_enabled` / `task_deps_source` (`plane|db|hybrid`).
Дефолты — обратная совместимость (для не-self репо — прежнее поведение).
## 8. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR)
- `06-adr/ADR-001-*.md` — решение по сериализации (A) и хранению зависимостей (B).
- Обновить `docs/architecture/README.md` (раздел про очередь/merge-gate/сериализацию).
- Обновить `CLAUDE.md` (паспорт: конвейер/инварианты, если меняется поведение очереди).
- Обновить `CHANGELOG.md` (`## [Unreleased]`).
- Если вводится таблица БД — отразить в `08-data-requirements.md` (создаёт архитектор).
- `07-infra-requirements.md` — если требуется новый Plane-статус/настройка relations.
## 9. Инварианты (НЕ нарушать)
1. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
4. never-raise во всех новых функциях; restart-safe состояние.
5. ORCH-026 дополняет рубежи ORCH-073, не заменяет.
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.

View File

@@ -0,0 +1,107 @@
# 03-Критерии приёмки — ORCH-026
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
Каждый критерий — проверяемое условие PASS/FAIL. Маппинг на тесты — `04-test-plan.yaml`.
---
## Уровень A — Сериализация merge/деплоя внутри одного репо
### AC-A1 — Сериализация merge внутри репо
- **PASS:** пока задача A применимого репо удерживает окно merge (merge-lease не освобождён /
`main` ещё не обновлён), задача B того же репо НЕ доходит до фактического merge — она
**defer**-ится (повторная постановка через `available_at`), а не мержится от устаревшего `main`.
- **FAIL:** B мержится/деплоится, пока A не в `main`; или B откатывается на `development` вместо
defer.
### AC-A2 — Proactive pre-merge rebase
- **PASS:** перед merge ветка задачи **всегда** догоняется на свежий `origin/main` (вызывается
rebase), даже когда текстового конфликта нет и ветка формально не «behind» по старой проверке;
после rebase ветка содержит код предшественника (A).
- **FAIL:** rebase запускается только при конфликте/`branch_is_behind_main`, и B мержится без
кода A.
### AC-A3 — Кросс-репо параллелизм сохранён
- **PASS:** задача в `orchestrator` и задача в `enduro-trails` доходят до merge/деплоя
параллельно — сериализация одного репо не блокирует другой (lease/гейт строго per-repo).
- **FAIL:** задача одного репо ждёт освобождения ресурса, удерживаемого задачей ДРУГОГО репо.
### AC-A4 — Restart-safe
- **PASS:** после рестарта прод-контейнера состояние сериализации восстанавливается корректно;
мёртвый держатель merge-lease проактивно реклеймится (ORCH-065), конвейер не встаёт навсегда.
- **FAIL:** рестарт оставляет навсегда захваченный lease → конвейер всех проектов встаёт.
### AC-A5 — Self-hosting safety
- **PASS:** прод-контейнер orchestrator НЕ рестартится/не падает вне штатного `Confirm Deploy`
(ORCH-059); нет push/force-push в `main`; `STAGE_TRANSITIONS` и реестр `QG_CHECKS` не изменены.
- **FAIL:** любой незапрошенный рестарт прода, прямой push в `main`, или изменение машины стадий.
### AC-A6 — Anti-deadlock / anti-livelock при defer
- **PASS:** при занятой сериализации B defer-ится с задержкой и bounded бюджетом; исчерпание
бюджета → эскалация (alert/Blocked), не бесконечный цикл и не откат.
- **FAIL:** B уходит в вечный defer-цикл, либо немедленно откатывается на `development`.
### AC-A7 — Условность (не-self репо без регресса)
- **PASS:** при выключенном kill-switch и для репо вне scope поведение конвейера 1:1 как до
ORCH-026 (нулевая регрессия для enduro-trails).
- **FAIL:** не-self репо меняет поведение merge/деплоя.
---
## Уровень B — Декларативные зависимости
### AC-B1 — Декларация зависимости
- **PASS:** задача может объявить `blocked-by`/`depends-on` (через выбранный источник —
Plane relations / БД / гибрid), и связь корректно считывается планировщиком.
- **FAIL:** связь не считывается / теряется.
### AC-B2 — Гейт планировщика (B не стартует до A)
- **PASS:** задача с незавершённым depends-on **не клеймится** воркером (не запускается агент,
слот `max_concurrency` не занимается), пока все depends-on не достигли `done`; как только A
становится `done` — B становится claimable.
- **FAIL:** B запускается раньше завершения A; или занимает слот, простаивая.
### AC-B3 — Детект дедлоков (циклы)
- **PASS:** циклическая зависимость (A→B→A и длиннее) детектируется детерминированно; задача(и)
в цикле → `Blocked` + alert (Telegram/Plane) с указанием цикла; поток остальных задач не
блокируется.
- **FAIL:** цикл приводит к молчаливому вечному ожиданию или к падению воркера.
### AC-B4 — Видимость заблокированной задачи
- **PASS:** заблокированная задача видна — Plane-статус `Blocked` и/или строка ожидания в
Telegram-карточке (что/кого ждёт); инвариант «одна карточка на задачу» сохранён.
- **FAIL:** заблокированная задача невидима наблюдателю.
### AC-B5 — Совместимость с reconciler/reaper
- **PASS:** `reconciler` F-1 НЕ «разблокирует» задачу, заблокированную по зависимости (как уже
делает для Blocked/Needs-Input, ORCH-060/068); reaper не реапит корректно ожидающую задачу.
- **FAIL:** reconciler продвигает заблокированную задачу мимо её depends-on.
---
## Общие (оба уровня)
### AC-G1 — never-raise
- **PASS:** любая ошибка (git/сеть/БД/Plane) в новой логике не пробрасывается в `advance_stage`/
воркер; деградирует консервативно (defer/skip/fail-closed), конвейер не падает.
- **FAIL:** необработанное исключение роняет воркер/монитор-поток.
### AC-G2 — Kill-switch
- **PASS:** глобальный kill-switch выключает фичу целиком → поведение 1:1 как до ORCH-026.
- **FAIL:** при выключенном флаге поведение изменено.
### AC-G3 — Документация обновлена (golden source)
- **PASS:** в ТОМ ЖЕ PR обновлены `docs/architecture/README.md`, `CLAUDE.md` (если изменилось
поведение очереди), `CHANGELOG.md`, заведён ADR в `06-adr/`. Reviewer проверяет.
- **FAIL:** код изменён, документация — нет (→ REQUEST_CHANGES).
### AC-G4 — Миграция БД безопасна (если применимо)
- **PASS:** миграция только аддитивная (`CREATE TABLE IF NOT EXISTS`/`_ensure_column`),
идемпотентна, безопасна на живой общей прод-БД; существующие данные enduro-trails не затронуты.
- **FAIL:** деструктивная миграция / изменение существующих колонок.
### AC-G5 — Тесты зелёные
- **PASS:** новые unit+integration тесты (`04-test-plan.yaml`) проходят; существующий
`pytest tests/ -q` остаётся зелёным (нет регресса merge-gate/merge-verify/reconciler/reaper).
- **FAIL:** красный pytest или регресс существующих тестов.

View File

@@ -0,0 +1,169 @@
work_item: ORCH-026
description: >
План тестов для управления зависимостями задач (Уровень B) и сериализации
merge/деплоя внутри одного репо (Уровень A). Стек: pytest. Имена модулей/функций —
кандидаты; финализирует архитектор/разработчик. Все новые функции — never-raise.
tests:
# ---------------- Уровень A: сериализация merge/деплоя ----------------
- id: TC-A01
type: unit
description: >
Proactive pre-merge rebase: ветка догоняется на свежий origin/main ДАЖЕ когда
branch_is_behind_main вернул бы False (нет конфликта). Проверить, что rebase
вызывается всегда перед merge (AC-A2).
module: tests/test_orch026_premerge_rebase.py
expected: PASS
- id: TC-A02
type: unit
description: >
Расширенное окно merge-lease: пока A держит lease (окно merge→main-updated),
acquire для B того же репо возвращает busy → defer (не откат). holder-aware
release не удаляет чужой lease (AC-A1, AC-A6).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A03
type: unit
description: >
Сериализация строго per-repo: lease/гейт orchestrator не влияет на задачу
enduro-trails — обе claimable параллельно (AC-A3).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A04
type: unit
description: >
Restart-safe + проактивный реклейм: мёртвый держатель lease (pid не жив)
реклеймится reclaim_stale_lease; конвейер не встаёт навсегда (AC-A4).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A05
type: unit
description: >
Anti-livelock defer: B defer-ится с available_at-задержкой и bounded бюджетом;
исчерпание → эскалация (Blocked/alert), не бесконечный цикл (AC-A6).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A06
type: unit
description: >
Условность/kill-switch: при выключенном флаге и для репо вне scope поведение
merge/деплоя 1:1 как до ORCH-026 — no-op (AC-A7, AC-G2).
module: tests/test_orch026_conditionality.py
expected: PASS
- id: TC-A07
type: unit
description: >
Self-hosting safety: новая логика никогда не делает push/force-push в main;
force только --force-with-lease на ветку задачи; STAGE_TRANSITIONS не изменены
(AC-A5).
module: tests/test_orch026_conditionality.py
expected: PASS
- id: TC-A08
type: integration
description: >
Сквозной сценарий: две задачи одного репо проходят deploy-staging→deploy; B не
доходит до merge, пока A не в main; после A→done B ребейзится на свежий main
(несёт код A) и мержится. main не теряет код A (AC-A1/AC-A2).
module: tests/test_orch026_serialize_integration.py
expected: PASS
# ---------------- Уровень B: декларативные зависимости ----------------
- id: TC-B01
type: unit
description: >
Чтение/декларация связи blocked-by из выбранного источника (Plane/БД/гибрид);
связь корректно резолвится в depends_on_task_id (AC-B1). never-raise при
недоступности источника → консервативно (нет связи или fail-closed по решению ADR).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B02
type: unit
description: >
Гейт готовности (leaf-функция): задача с незавершённым depends-on НЕ ready;
все depends-on в done → ready. Чистая логика, юнит-тестируемая (AC-B2).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B03
type: unit
description: >
Детект циклов: A→B→A (и длиннее) детектируется детерминированно; ацикличный
граф → циклов нет. Чистая функция (AC-B3).
module: tests/test_orch026_dep_cycles.py
expected: PASS
- id: TC-B04
type: unit
description: >
Цикл → set_issue_blocked + alert (Telegram/Plane), без падения воркера и без
блокировки потока других задач (AC-B3, AC-G1).
module: tests/test_orch026_dep_cycles.py
expected: PASS
- id: TC-B05
type: unit
description: >
claim_next_job не клеймит заблокированную задачу (не занимает слот
max_concurrency); как только depends-on done — задача становится claimable (AC-B2).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B06
type: unit
description: >
Видимость: заблокированная задача отражается в Plane-статусе Blocked и/или
строке ожидания Telegram-карточки; инвариант «одна карточка на задачу» сохранён
(AC-B4). notifications never-raise / silent.
module: tests/test_orch026_dep_visibility.py
expected: PASS
- id: TC-B07
type: unit
description: >
reconciler F-1 НЕ разблокирует задачу, заблокированную по зависимости (как для
Blocked/Needs-Input); reaper не реапит корректно ожидающую (AC-B5).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B08
type: integration
description: >
Сквозной сценарий: B объявлена blocked-by A; при постановке в очередь B не
стартует, пока A не done; после A→done воркер запускает B. Telegram/Plane
показывают Blocked у B до разблокировки (AC-B1/B2/B4).
module: tests/test_orch026_deps_integration.py
expected: PASS
# ---------------- Общие / миграция / регресс ----------------
- id: TC-G01
type: unit
description: >
Аддитивная миграция БД (если выбран вариант с таблицей/колонкой): идемпотентна,
безопасна на существующей БД с данными, не меняет существующие колонки (AC-G4).
module: tests/test_orch026_migration.py
expected: PASS
- id: TC-G02
type: unit
description: >
Наблюдаемость GET /queue: новый блок (заблокированные задачи / держатель lease /
defer-счётчики / циклы) присутствует и read-only; не источник истины.
module: tests/test_orch026_queue_observability.py
expected: PASS
- id: TC-G03
type: integration
description: >
Регресс: полный pytest tests/ -q остаётся зелёным — merge-gate (ORCH-043),
merge-verify (ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не
деградировали (AC-G5).
module: tests/
expected: PASS

View File

@@ -0,0 +1,226 @@
# ADR-001: Сериализация merge/деплоя внутри репо (A) + декларативные зависимости задач (B)
**Work Item:** ORCH-026 · **Repo:** orchestrator (self-hosting) · **Стадия:** architecture
**Статус:** Accepted
**Связи:** дополняет ORCH-043 (merge-gate), ORCH-065 (merge-lease + reclaim), ORCH-073/071
(merge-verify, SHA-in-main), ORCH-1 (очередь). Глобальный ADR — `adr/adr-0015`.
---
## Контекст
ORCH-026 закрывает **первопричину** эрозии `main` 08.06 (некоординированный параллелизм
задач одного репо: ветки от устаревшего `main`, фантом-merge затирает соседа) и попутно вводит
исходный скоуп — декларативные зависимости задач (B ждёт A). Требования — `01-brd.md`,
`02-trz.md`; PASS/FAIL — `03-acceptance-criteria.md`.
Ключевое наблюдение архитектора: **бо́льшая часть инфраструктуры для Уровня A уже существует** и
её НЕ нужно переписывать:
- **merge-lease** (ORCH-065, `src/merge_gate.py`): per-repo файловый лиз
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный реклейм
мёртвого/устаревшего держателя (`reclaim_stale_lease`, `pid_alive`). Restart-safe, per-repo.
- **merge-gate** (ORCH-043, `check_branch_mergeable`): на ребре `deploy-staging → deploy`
захватывает лиз, при необходимости ребейзит, держит лиз до фактического merge.
- **defer-механизм** (`_handle_merge_gate_defer`): `merge-lock busy` → повторная постановка
deployer'а через `available_at`, bounded `merge_defer_max_attempts` → эскалация (Blocked+alert).
- **окно лиза** уже простирается от `deploy-staging → deploy` до release на одном из событий:
PR-merged webhook (`gitea.py`), `deploy→done` (`stage_engine.py`), откат, проактивный реклейм.
Для self-hosting `done` достигается ТОЛЬКО после `verify_merged_to_main` (SHA-in-main, ORCH-073).
Таким образом окно сериализации A-1 («merge → main-updated») **структурно уже реализовано**:
пока A не подтверждена в `main` (для self — SHA-in-main → `done`), лиз держится, и B того же
репо на своём merge-gate получает `merge-lock busy` → defer. Открытый вопрос BRD §4.3 (граница
окна для self) решается так: **окно = от acquire до release; release-события не меняем**. Для
non-self репо граница — PR-merged webhook; для self — `deploy→done` (= SHA-in-main подтверждён).
Что реально **отсутствует** для Уровня A:
- **A-2: безусловный proactive pre-merge rebase.** Сейчас `check_branch_mergeable` ребейзит
ТОЛЬКО если `branch_is_behind_main` (⇔ `origin/main` не предок HEAD). AC-A2 требует, чтобы
rebase вызывался **всегда** перед merge — детерминированный структурный анти-фантом на уровне
планировщика, не зависящий от точности ancestor-проверки.
Для Уровня B инфраструктуры нет вовсе: очередь `jobs` (ORCH-1) плоская (FIFO по `id` +
`available_at` + `max_concurrency`), выразить «B ждёт A» нельзя.
---
## Решение
### Уровень A — сериализация merge/деплоя (минимально-инвазивно, переиспользуя ORCH-043/065)
**A-1/A-3/A-4 (окно сериализации) — без изменений механизма.** Окно сериализации обеспечивается
существующим merge-lease: захват в `check_branch_mergeable`, удержание до release. Подтверждаем и
фиксируем в доке, что release-события (`PR-merged` / `deploy→done` / откат / `reclaim_stale_lease`)
формируют окно «merge → main-updated». Кросс-репо параллелизм сохранён автоматически (лиз —
per-repo файл). Restart-safe и анти-залипание — за счёт ORCH-065 reclaim. **Кода-изменений нет.**
**A-2 (безусловный pre-merge rebase) — новое поведение, флаг `premerge_rebase_always`.**
- В `check_branch_mergeable` (`src/qg/checks.py`), ПОД захваченным merge-lease: когда
`settings.premerge_rebase_always` истинно (и merge-gate применим к репо), **пропустить
short-circuit `branch_is_behind_main`** и **всегда** вызвать `merge_gate.auto_rebase_onto_main`.
- `auto_rebase_onto_main` уже идемпотентен и дёшев на актуальной ветке: `git rebase origin/main`
на не-отстающей ветке — no-op (rc 0, HEAD не меняется), последующий `push --force-with-lease`
→ «Everything up-to-date» (тот же SHA, **CI не перезапускается, лишних коммитов нет**). На
отстающей ветке — реальный догон. Текстовый конфликт → существующий контракт: `rebase --abort`
→ откат на `development` (как ORCH-043). **Инвариант: никаких push/force-push в `main`**
единственная force-операция остаётся `--force-with-lease` на ветку задачи.
- Когда флаг выключен → прежнее поведение (ребейз только при `branch_is_behind_main`),
обратная совместимость 1:1 (AC-A7/AC-G2).
- **Скоуп — общий с merge-gate:** реально только для `merge_gate_repos` (пусто → self-hosting
`orchestrator`). Никакого нового scope-флага.
**A-5/A-6 (safety, anti-livelock) — без изменений.** `STAGE_TRANSITIONS`, `QG_CHECKS`,
`Confirm Deploy` (ORCH-059), exit-коды хука, terminal-sync не трогаются. defer-бюджет —
существующий `merge_defer_max_attempts` → Blocked+alert при исчерпании. Прод-контейнер не
рестартится вне штатного `Confirm Deploy`.
### Уровень B — декларативные зависимости (новая инфраструктура)
**B-источник: гибрид с БД как источником истины для планировщика; флаг `task_deps_source`.**
Планировщик `claim_next_job` — горячий цикл, обслуживающий очередь ВСЕХ проектов из ОДНОГО
инстанса. Он **обязан** быть offline-устойчивым и быстрым: сетевой запрос в Plane на каждый claim
= при недоступности Plane встанет конвейер всех проектов (нарушение self-hosting safety). Поэтому:
- **Авторитетный для планировщика стор — локальная БД**, новая аддитивная таблица
`job_deps(task_id, depends_on_task_id, created_at)` (детали — `08-data-requirements.md`).
Связь хранится по `tasks.id` (стабильный локальный ключ). Зависимости — **только внутри одного
репо** (v1; кросс-репо — non-goal, BRD §5).
- **`task_deps_source = db | plane | hybrid`** (дефолт **`db`**): `db` — связи пишутся напрямую в
`job_deps` (потребитель — декомпозиция эпиков ORCH-025); `plane` — связи читаются из Plane
relations в `handle_work_item_created` и **кэшируются** в `job_deps`; `hybrid` — Plane как
декларация + БД-кэш. Plane-ingestion — тонкий add-on за флагом; планировщик ВСЕГДА читает БД.
**B-2 (гейт планировщика) — SQL `NOT EXISTS`, без занятия слота `max_concurrency`.**
Гейт готовности выражается декларативно в `claim_next_job` (`src/db.py`): задача claimable, если
у неё нет ни одной незавершённой зависимости. Когда `settings.task_deps_enabled` — к существующему
SELECT добавляется условие:
```sql
AND NOT EXISTS (
SELECT 1 FROM job_deps d
JOIN tasks t ON t.id = d.depends_on_task_id
WHERE d.task_id = j.task_id AND t.stage != 'done'
)
```
Это: (1) **не занимает слот** — job просто не выбирается, агент не запускается (AC-B2);
(2) restart-safe (чистая БД); (3) never-raise (это SQL); (4) при пустой `job_deps`
инертно (нулевая регрессия, AC-G2); (5) при выключенном `task_deps_enabled` условие НЕ
добавляется → запрос 1:1 как в ORCH-1. Как только все зависимости достигают `stage='done'`,
задача автоматически становится claimable.
Чистая leaf-логика «готова ли задача» выносится в новый модуль `src/task_deps.py`:
`is_task_ready(task_id) -> (bool, waiting_on: list[str])` (never-raise) — для реконсилятора,
карточки и `/queue` (SQL в `claim_next_job` — горячий путь, дублирует ту же семантику).
**B-3 (детект дедлоков) — DFS, чистая функция.**
`task_deps.detect_cycle(task_id) -> list[int] | None` — обход графа `job_deps` (внутри репо),
детерминированный, юнит-тестируемый, never-raise. Запускается: (1) при вставке связи
(`add_dependency`) — цикл отклоняется/алертится сразу (лучший UX); (2) backstop-проход в тике
`reconciler` (на случай связей, добавленных в обход). Цикл → `set_issue_blocked(work_item_id)` +
Telegram/Plane alert с перечислением цикла. SQL-гейт B-2 сам по себе никогда не выберет задачу в
цикле (её зависимости не достигнут `done`) — детектор делает это **видимым**, а не молчаливым
вечным ожиданием (AC-B3). Поток остальных задач не блокируется.
**B-4 (видимость).**
- Нормальное ожидание (B ждёт A, A в работе — транзиентно и ожидаемо): строка в Telegram-карточке
«⏳ ждёт ORCH-NNN» через `notifications.update_task_tracker`, never-raise/silent. **Plane Blocked
при нормальном ожидании НЕ ставим** — иначе флаппинг Blocked на каждом коротком ожидании.
- Дедлок/цикл (B-3): `set_issue_blocked` (Plane `Blocked`) + alert. Это «и/или» из AC-B4.
- Инвариант «одна карточка на задачу» сохранён (ORCH-042/067).
**B-5 (совместимость reconciler/reaper).**
- `reconciler` F-1 не должен «разблокировать» dep-заблокированную задачу мимо её зависимостей.
В фильтр пригодности reconciler добавляется проверка `task_deps.is_task_ready` (по образцу
`reconcile_skip_blocked_enabled`, ORCH-060): не готова → skip.
- `reaper` сканирует **`running`** jobs; dep-заблокированный job остаётся `queued` (его не
клеймят) → reaper его не трогает по построению. Фиксируем в доке.
**Наблюдаемость (TRZ §4):** блок `task_deps` в снимке `GET /queue` (read-only, по образцу
`reconcile`/`reaper`): кол-во заблокированных задач, держатель merge-lease, defer-счётчики,
обнаруженные циклы. Никогда не источник решений.
### Конфигурация (`src/config.py`)
| Флаг | Дефолт | Назначение |
|------|--------|-----------|
| `premerge_rebase_always` | `True` | Уровень A: безусловный pre-merge rebase под лизом. Скоуп — `merge_gate_repos`. Kill-switch (`False` → ребейз только при behind, как ORCH-043). |
| `task_deps_enabled` | `True` | Уровень B: глобальный kill-switch гейта зависимостей. `False``claim_next_job` 1:1 как ORCH-1. Инертно при пустой `job_deps`. |
| `task_deps_source` | `"db"` | Источник деклараций: `db`\|`plane`\|`hybrid`. Планировщик всегда читает БД-кэш. |
Дефолты следуют конвенции репо (`*_enabled=True` + kill-switch), при этом обе фичи инертны без
данных (нет деклараций / нет применимых репо) → нулевая регрессия для enduro-trails.
---
## Альтернативы (и почему отвергнуты)
1. **Уровень A — отдельный глобальный per-repo merge-lock или FIFO merge-queue.** Дублировал бы
уже существующий merge-lease (ORCH-065), вводил второй механизм сериализации с риском
рассинхрона. Отвергнуто: BRD §4.2 требует минимально-инвазивного решения, переиспользующего
ORCH-065/043. Окно лиза уже даёт сериализацию.
2. **Уровень A — расширять release-точки лиза (держать до отдельного `main-updated`-события).**
Не требуется: для self `done` ⇔ SHA-in-main (ORCH-073), для non-self — PR-merged webhook;
окно уже корректно. Доп. событие усложнило бы reclaim без выигрыша.
3. **Уровень B — Plane relations как источник истины планировщика.** Сетевой запрос в горячем
цикле claim; при недоступности Plane встаёт очередь всех проектов (self-hosting risk).
Отвергнуто; Plane оставлен опциональным источником **декларации** (`task_deps_source=plane`),
но планировщик читает только БД-кэш.
4. **Уровень B — гейт зависимостей в воркере (`_drain_once`) поверх `claim_next_job`.** Пришлось
бы клеймить job, обнаруживать незавершённую зависимость и re-queueить — churn, расход attempts,
гонки. SQL `NOT EXISTS` в самом `claim_next_job` чище: job просто не выбирается, слот свободен.
5. **Уровень B — поле/JSON в `tasks` вместо таблицы.** Таблица `job_deps` нормальна (M:N),
индексируема, проще для DFS и `NOT EXISTS`. Поле в `tasks` потребовало бы парсинг-логики.
---
## Последствия
**Плюсы.**
- Минимально-инвазивно: Уровень A — один флаг + снятие short-circuit; окно сериализации не
переписывается. Переиспользует ORCH-043/065 целиком.
- Уровень B — одно `NOT EXISTS` в `claim_next_job` + аддитивная таблица + leaf-модуль
`task_deps.py`; `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки ORCH-071/058).
- Обе фичи инертны без данных → нулевая регрессия для enduro-trails (AC-A7/AC-G2).
- restart-safe (БД + файловый лиз), never-raise, kill-switch на каждую фичу.
**Минусы / ограничения.**
- `premerge_rebase_always=True` добавляет (дешёвый, no-op на актуальной ветке) `rebase`+`push`
на каждый self-merge. Цена — лишний git-вызов; компенсируется детерминизмом анти-фантома.
- Уровень B v1 — только intra-repo зависимости; кросс-репо — follow-up (non-goal).
- Гейт B-2 в `claim_next_job` слегка усложняет горячий SQL (один `NOT EXISTS`); защищён
kill-switch и инертностью при пустой таблице.
- `task_deps.py` цикл-детектор — новая поверхность; покрывается юнит-тестами (`04-test-plan.yaml`).
**Инварианты (не нарушать).**
1. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
4. never-raise во всех новых функциях; restart-safe состояние; миграция БД только аддитивная.
5. ORCH-026 **дополняет** рубежи ORCH-073, не заменяет.
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.
**Места реализации (для developer).**
- `src/qg/checks.py::check_branch_mergeable` — ветка `premerge_rebase_always`.
- `src/db.py::claim_next_job` — условный `NOT EXISTS`-гейт; новые helpers `add_dependency`,
`get_dependencies`, `job_deps` миграция в `init_db` (`CREATE TABLE IF NOT EXISTS`).
- `src/task_deps.py` (новый, leaf) — `is_task_ready`, `detect_cycle`, snapshot для `/queue`.
- `src/webhooks/plane.py::handle_work_item_created` — ingestion Plane relations (за `task_deps_source`).
- `src/reconciler.py` — skip dep-заблокированных + backstop цикл-детект.
- `src/notifications.py` — строка ожидания в карточке.
- `src/config.py``premerge_rebase_always`, `task_deps_enabled`, `task_deps_source`.
- Документация: `docs/architecture/README.md`, `CLAUDE.md` (если меняется поведение очереди),
`CHANGELOG.md`, глобальный `adr/adr-0015`.

View File

@@ -0,0 +1,65 @@
# 08 — Требования к схеме БД — ORCH-026
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md` (Уровень B).
> Уровень A (сериализация merge/деплоя) — **БЕЗ изменения схемы БД** (merge-lease файловый,
> `.merge-lease-<repo>.json`, ORCH-065). Изменения схемы касаются ТОЛЬКО Уровня B.
---
## Новая таблица `job_deps` (аддитивная)
Хранит декларативные зависимости «задача `task_id` ждёт задачу `depends_on_task_id`».
```sql
CREATE TABLE IF NOT EXISTS job_deps (
task_id INTEGER NOT NULL, -- tasks.id зависимой задачи (B)
depends_on_task_id INTEGER NOT NULL, -- tasks.id задачи-предшественника (A)
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (task_id, depends_on_task_id)
);
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
```
### Поля
| Поле | Тип | Назначение |
|------|-----|-----------|
| `task_id` | INTEGER | `tasks.id` зависимой задачи (B). Не запускается, пока зависимости не `done`. |
| `depends_on_task_id` | INTEGER | `tasks.id` предшественника (A). Терминальность — `tasks.stage = 'done'`. |
| `created_at` | TEXT | Время декларации (диагностика). |
### Ключ и индексы
- **PK `(task_id, depends_on_task_id)`** — идемпотентность вставки (повторная декларация связи —
no-op через `INSERT OR IGNORE`), запрет дублей.
- `idx_job_deps_task` — гейт планировщика (`NOT EXISTS ... WHERE d.task_id = j.task_id`).
- `idx_job_deps_depends` — обратные рёбра для DFS цикл-детектора.
### Семантика готовности (источник истины планировщика)
Задача `task_id` **готова к запуску** ⇔ нет ни одной строки `job_deps` для неё, чей
`depends_on_task_id` указывает на задачу с `tasks.stage != 'done'`. Терминал — только `done`
(совпадает с тем, как `get_active_tasks_for_reconcile` трактует терминальность).
### Связь по `task_id`, а не `work_item_id`
`tasks.id` — стабильный локальный автоинкремент-ключ; `work_item_id`/`plane_id` могут
ресолвиться/коллизиться (см. `ensure_unique_work_item_id`). FK логический (без `REFERENCES`,
как у `jobs.task_id`) — не блокирует аддитивную миграцию и удаление строк tasks (которого в
конвейере нет). Зависимости — **только intra-repo** (v1); кросс-репо рёбра не создаются.
---
## Миграция (AC-G4)
- Выполняется в `src/db.py::init_db` рядом с прочими: **только** `CREATE TABLE IF NOT EXISTS` +
`CREATE INDEX IF NOT EXISTS`. **Идемпотентно**, restart-safe, безопасно на живой общей прод-БД.
- **Существующие колонки/таблицы (`jobs`, `tasks`, `agent_runs`, `events`) НЕ изменяются** →
данные enduro-trails не затронуты.
- Откат фичи — флагом `task_deps_enabled=False` (таблица остаётся, гейт не применяется); сама
таблица деструктивно не удаляется.
## Что НЕ меняется
- Схема `jobs` (включая `available_at`, `pid`, `attempts`/`transient_attempts`) — без изменений;
defer Уровня A/B переиспользует существующий `available_at`-механизм.
- Схема `tasks` — без изменений (видимость через существующие `tracker_message_id` и Plane Blocked).
- merge-lease — файловый, вне БД.

View File

@@ -0,0 +1,17 @@
# 10 — Технические риски — ORCH-026
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md`.
| # | Риск | Уровень | Митигация |
|---|------|---------|-----------|
| R-1 | **Гейт `NOT EXISTS` в `claim_next_job` (горячий путь всех проектов) содержит баг → встаёт очередь ВСЕХ проектов** (self-hosting групповой риск). | Высокий | Условие добавляется ТОЛЬКО при `task_deps_enabled`; инертно при пустой `job_deps` (нулевая регрессия); kill-switch `task_deps_enabled=False` мгновенно возвращает поведение ORCH-1; интеграционный тест «пустые deps ⇒ FIFO 1:1» (AC-G2). |
| R-2 | **Безусловный `premerge_rebase_always` делает лишний `push --force-with-lease` → ложный перезапуск CI / новые коммиты.** | Низкий | На актуальной ветке `rebase origin/main` — no-op (HEAD не меняется), push → «Everything up-to-date» (тот же SHA, CI не триггерится). Подтвердить тестом, что SHA не меняется на уже-актуальной ветке. |
| R-3 | **Дедлок по циклической зависимости → задача молча ждёт вечно.** | Средний | DFS-детектор `detect_cycle` при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert с перечислением цикла (AC-B3); SQL-гейт не выбирает задачу в цикле, детектор делает это видимым. |
| R-4 | **Livelock: B бесконечно deferится на `merge-lock busy`.** | Низкий | Существующий bounded-бюджет `merge_defer_max_attempts` → Blocked+alert (ORCH-043, без изменений). |
| R-5 | **Залипший merge-lease после смерти держателя → конвейер репо встаёт навсегда.** | Средний | Переиспользуется ORCH-065: `reclaim_stale_lease` (мёртвый `pid` / TTL `merge_lock_timeout_s`) + holder-aware release. Restart-safe (AC-A4). |
| R-6 | **Plane relations недоступны/неверно смаплены при `task_deps_source=plane`.** | Средний | Планировщик читает ТОЛЬКО БД-кэш `job_deps`; Plane-ingestion — best-effort, never-raise; дефолт `task_deps_source=db` не зависит от Plane. |
| R-7 | **reconciler «разблокирует» dep-заблокированную задачу мимо её зависимостей.** | Средний | В фильтр reconciler добавляется `is_task_ready` (паттерн ORCH-060 skip-Blocked); reaper трогает только `running` — dep-блок остаётся `queued` (AC-B5). |
| R-8 | **Миграция БД повреждает общую прод-БД (данные enduro-trails).** | Низкий | Только аддитивно: `CREATE TABLE/INDEX IF NOT EXISTS`; существующие колонки не меняются; идемпотентно (AC-G4). |
| R-9 | **Self-hosting: изменения требуют рестарта прод-контейнера вне `Confirm Deploy`.** | Высокий (если нарушено) | Все изменения — обычный код, проходят `deploy-staging` (8501) → `Confirm Deploy` (ORCH-059). `STAGE_TRANSITIONS`/`QG_CHECKS` не трогаются; никакого внеочередного рестарта (AC-A5). |
| R-10 | **Конфликт точек интеграции A (merge-gate) и B (постановка в очередь).** | Низкий | Разные точки конвейера: B гейтит claim job (вход), A гейтит merge на ребре `deploy-staging→deploy`. Независимы; покрыть интеграционным тестом совместной работы (BRD §4.4). |

View File

@@ -0,0 +1,47 @@
---
type: review
work_item_id: ORCH-026
verdict: APPROVED
version: 1
---
# Review ORCH-026
## Summary
ORCH-026 реализует два уровня по ADR-001: **Уровень A** — сериализация merge/deploy внутри одного репо (переиспользует merge-lease ORCH-043/065 + единственная новая логика — безусловный pre-merge rebase под флагом `premerge_rebase_always`) и **Уровень B** — декларативные зависимости задач (аддитивная таблица `job_deps`, гейт `NOT EXISTS` в `claim_next_job`, leaf-модуль `src/task_deps.py`). Реализация минимально-инвазивна, строго соответствует ТЗ и ADR, обе фичи условны (kill-switch) и инертны без данных. Все 16 критериев приёмки выполнены. Полный прогон `pytest tests/ -q`**991 passed**, из них 50 новых ORCH-026-тестов зелёные. Документация обновлена в том же PR. **APPROVED.**
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- (нет)
### P3 — Nice to have
- [ ] PR-ветка несёт коммиты ORCH-073 (`main` ещё не получил merge #77, merge-base = `77abfb3`). Это ожидаемо по топологии (ORCH-026 (B) построен поверх уже отревьюенного предшественника ORCH-073 (A): у ORCH-073 есть собственные `12-review.md`/`13-test-report.md`/`14-deploy-log.md`) и фактически демонстрирует саму фичу A (rebase B на код A). Не блокирует; при merge в `main` приедут оба набора изменений — это корректно.
## Соответствие ТЗ и ADR
- **Уровень A (AC-A1…A7):** окно сериализации обеспечено существующим merge-lease без нового механизма (ADR §A-1/A-3/A-4). A-2 — `check_branch_mergeable` (`src/qg/checks.py`) под лизом при `premerge_rebase_always=True` всегда вызывает `auto_rebase_onto_main`, снимая short-circuit `branch_is_behind_main`; kill-switch off → поведение ORCH-043 1:1. `STAGE_TRANSITIONS`/`QG_CHECKS`/`Confirm Deploy` не тронуты — соответствует инвариантам §9. Никаких push/force в `main` (только `--force-with-lease` ветки).
- **Уровень B (AC-B1…B5):** гейт `NOT EXISTS (job_deps JOIN tasks WHERE stage!='done')` в `claim_next_job` (`src/db.py`) — job не выбирается, слот `max_concurrency` не занимается; при выключенном флаге / пустой таблице clause не добавляется (нулевая регрессия). `task_deps.py` — чистый leaf: `is_task_ready` (fail-open), итеративный WHITE/GREY/BLACK DFS-детектор циклов (защита от recursion-limit на проде), `handle_cycle` (Blocked+alert), `declare_dependency`, `ingest_plane_relations` (только `plane|hybrid`, дефолт `db` не ходит в сеть на горячем пути). reconciler F-1 получил Guard 3 (skip dep-заблокированных + backstop детект цикла); reaper не тронут (сканирует `running`).
- **Общие (AC-G1…G5):** контракт never-raise выдержан во всех новых функциях (try/except, консервативная деградация). Миграция строго аддитивна — `CREATE TABLE/INDEX IF NOT EXISTS`, без `REFERENCES`, схема `tasks`/`jobs` не изменена (AC-G4 OK на живой общей БД). Наблюдаемость — read-only блок `task_deps` в `GET /queue`. Реализация в точности по местам, указанным в ADR §«Места реализации».
## Качество кода
- Docstrings на всех публичных функциях, явно документирован контракт fail-open/fail-closed.
- SQL-гейт безопасен: `dep_gate` — константная строка (нет инъекции), таблица `job_deps` гарантированно создана в `init_db`.
- Переменные `plane_id`/`plane_project_id`/`task_id` в `start_pipeline` — в области видимости (проверено).
- Тесты содержательные: миграция, conditionality (kill-switch), циклы, видимость, observability, интеграция сериализации и зависимостей.
## Документация — обновлена (golden source)
Проверено: код в `src/` изменён → документация обновлена В ТОМ ЖЕ PR (разнесена по pipeline-коммитам ветки, что нормально):
- `docs/architecture/README.md` — разделы про очередь (`claim_next_job`-гейт), pre-merge rebase, «Зависимости задач: B ждёт A», `job_deps`, наблюдаемость (architect-коммит `f8ec1c2`). ✓
- `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md` + глобальный `docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md`. ✓
- `CLAUDE.md` — паспорт (очередь/сериализация). ✓
- `CHANGELOG.md` — запись `## [Unreleased]`. ✓
- `.env.example``ORCH_PREMERGE_REBASE_ALWAYS`/`ORCH_TASK_DEPS_ENABLED`/`ORCH_TASK_DEPS_SOURCE`. ✓
- `08-data-requirements.md` — таблица `job_deps`. ✓
Документация = golden source: требование выполнено.

View File

@@ -0,0 +1,75 @@
---
type: test-report
work_item_id: ORCH-026
result: PASS
---
# Test Report — ORCH-026
Задача: «Управление зависимостями задач (B ждёт A) в очереди» + сериализация merge/деплоя
одного репо. Ветка `feature/ORCH-026-b-a`. Review-вердикт: **APPROVED** (`12-review.md`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: `feature/ORCH-026-b-a` (HEAD `aaa4829`)
- Прод-оркестратор (8500): `/health``{"status":"ok"}` (не перезапускался, self-hosting инвариант соблюдён)
- Дата: 2026-06-08
## Результаты по тест-плану (04-test-plan.yaml)
### Уровень A — сериализация merge/деплоя
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-A01 | Proactive pre-merge rebase (всегда, даже когда не behind) | `test_orch026_premerge_rebase::test_always_rebases_even_when_not_behind` | PASS |
| TC-A02 | Расширенное окно merge-lease, defer не откат; holder-aware release | `test_orch026_merge_serialize::test_second_task_same_repo_defers_not_rollback`, `test_holder_aware_release_keeps_foreign_lease` | PASS |
| TC-A03 | Сериализация строго per-repo (orchestrator ≠ enduro-trails) | `test_orch026_merge_serialize::test_serialization_is_strictly_per_repo` | PASS |
| TC-A04 | Restart-safe + реклейм мёртвого держателя lease | `test_orch026_merge_serialize::test_dead_holder_lease_is_reclaimed`, `test_stale_lease_age_reclaimed_on_acquire` | PASS |
| TC-A05 | Anti-livelock defer: bounded бюджет, эскалация | `test_orch026_merge_serialize::test_defer_budget_is_bounded` | PASS |
| TC-A06 | Условность/kill-switch: off + out-of-scope = no-op | `test_orch026_conditionality::test_out_of_scope_repo_is_noop_even_with_flag_on`, `test_premerge_rebase::test_flag_off_short_circuits_like_orch043` | PASS |
| TC-A07 | Self-hosting safety: только `--force-with-lease` на ветку, STAGE_TRANSITIONS не тронуты | `test_orch026_conditionality::test_premerge_only_force_with_lease_on_branch`, `test_stage_transitions_unchanged` | PASS |
| TC-A08 | Сквозной сценарий сериализации merge-окна | `test_orch026_serialize_integration::test_serialized_merge_window` | PASS |
### Уровень B — декларативные зависимости
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-B01 | Декларация/резолв blocked-by; never-raise при недоступности | `test_orch026_task_deps::test_add_dependency_declares_and_resolves`, `test_add_dependency_never_raises_on_bad_input` | PASS |
| TC-B02 | Гейт готовности: незавершённый depends-on → не ready; все done → ready | `test_orch026_task_deps::test_is_task_ready_blocked_then_ready`, `test_is_task_ready_no_deps_is_ready` | PASS |
| TC-B03 | Детект циклов A→B→A и длиннее; ацикличный → нет | `test_orch026_dep_cycles::test_detect_two_node_cycle`, `test_detect_longer_cycle`, `test_acyclic_graph_has_no_cycle`, `test_detect_cycle_never_raises_on_garbage` | PASS |
| TC-B04 | Цикл → Blocked + alert без падения воркера | `test_orch026_dep_cycles::test_handle_cycle_blocks_and_alerts`, `test_handle_cycle_never_raises_when_notify_fails` | PASS |
| TC-B05 | claim_next_job не клеймит заблокированную (слот свободен), разблокируется при done | `test_orch026_task_deps::test_claim_skips_dep_blocked_job`, `test_claim_prefers_unblocked_job_over_blocked` | PASS |
| TC-B06 | Видимость: строка ожидания в карточке; never-raise рендер | `test_orch026_dep_visibility::test_blocked_task_shows_waiting_line`, `test_render_never_raises_on_dep_error` | PASS |
| TC-B07 | reconciler F-1 не разблокирует dep-заблокированную | `test_orch026_task_deps::test_reconciler_skip_helper_honours_block` | PASS |
| TC-B08 | Сквозной: B стартует только после A→done; multiple predecessors | `test_orch026_deps_integration::test_b_waits_for_a_then_runs`, `test_multiple_predecessors_all_must_be_done`, `test_ingest_plane_relations_writes_db` | PASS |
### Общие / миграция / регресс
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-G01 | Аддитивная миграция job_deps: идемпотентна, данные сохранены | `test_orch026_migration::test_job_deps_table_created`, `test_job_deps_indices_created`, `test_migration_idempotent_and_preserves_data` | PASS |
| TC-G02 | Наблюдаемость GET /queue: read-only блок task_deps | `test_orch026_queue_observability::test_queue_endpoint_includes_task_deps`, `test_snapshot_*` | PASS |
| TC-G03 | Регресс: полный pytest зелёный | `tests/` (991 passed) | PASS |
## Smoke test API (прод 8500)
- `GET /health``{"status":"ok","service":"orchestrator"}` — OK
- `GET /status` → активные задачи отдаются, ORCH-026 (id 58) в стадии `testing` — OK
- `GET /queue` → counts/resilience/reconcile/reaper/merge_verify читаются; брейкер `closed`, preflight OK — OK
- Примечание: блок `task_deps` в `/queue` прода 8500 ОТСУТСТВУЕТ — ожидаемо: прод-контейнер несёт текущую задеплоенную версию, ORCH-026 ещё не выкатан (self-hosting, деплой на поздних стадиях). Фича наблюдаемости верифицирована in-branch тестом `test_queue_endpoint_includes_task_deps` (PASS) через TestClient на коде ветки.
## Вывод pytest
```
tests/test_orch026_*.py — 50 passed, 1 warning in 1.56s
tests/ — 991 passed, 1 warning in 26.52s
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, предсуществующий, не относится к ORCH-026)
## Покрытие критериев приёмки (03-acceptance-criteria.md)
Все 16 критериев (AC-A1…A7, AC-B1…B5, AC-G1…G5) покрыты прохождением соответствующих TC и
подтверждены review-вердиктом APPROVED. Регрессии merge-gate (ORCH-043), merge-verify
(ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не обнаружено.
## Итог
**PASS** — 50/50 новых ORCH-026-тестов зелёные, полный регресс 991 passed, smoke API OK,
прод-контейнер не затронут. Задача готова к переходу на `deploy-staging`.

View File

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

130
src/db.py
View File

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

View File

@@ -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),
}

View File

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

View File

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

View File

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

View File

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

335
src/task_deps.py Normal file
View File

@@ -0,0 +1,335 @@
"""ORCH-026 (Level B): declarative task-dependency logic.
Leaf module — pure, unit-testable functions over the additive ``job_deps`` table
(see src/db.py / 08-data-requirements.md). It answers two questions the rest of
the pipeline asks:
* "is task B ready to run?" — every declared predecessor A reached
``tasks.stage = 'done'`` (``is_task_ready``). The scheduler gate in
``db.claim_next_job`` enforces the same predicate in SQL; this Python copy is
for the reconciler skip and for naming WHAT a task waits on (visibility).
* "is there a dependency deadlock?" — a directed cycle A->B->A (or longer) can
never be satisfied, so the tasks in it would wait forever. ``detect_cycle`` /
``find_any_cycle`` find one deterministically; ``handle_cycle`` escalates it
to Blocked + alert so the deadlock is visible instead of silent.
never-raise contract (AC-G1, self-hosting safety): EVERY public function
degrades conservatively on any error (DB/import) and NEVER propagates an
exception into the worker / reconciler / webhook. Readiness fails OPEN
(``True``) so a transient DB error cannot wedge the whole queue; cycle detection
fails CLOSED-safe (``None`` = "no cycle proven", do not block).
"""
from __future__ import annotations
import logging
from . import db
from .config import settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Readiness gate (B-2)
# ---------------------------------------------------------------------------
def is_task_ready(task_id: int) -> tuple[bool, list[str]]:
"""Return ``(ready, waiting_on)`` for a task.
``ready`` is True when the task has no declared dependency whose predecessor
is still un-done (``tasks.stage != 'done'``). ``waiting_on`` is the list of
predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still blocked
by — used for the Telegram waiting-line / Plane visibility.
never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the
scheduler omitting the gate when the DB read fails; a transient error must
not wedge an otherwise-claimable task).
"""
if task_id is None:
return True, []
try:
unfinished = db.get_unfinished_dependencies(task_id)
except Exception:
return True, []
if not unfinished:
return True, []
waiting_on = [
str(d.get("work_item_id") or d.get("id"))
for d in unfinished
]
return False, waiting_on
# ---------------------------------------------------------------------------
# Cycle / deadlock detection (B-3)
# ---------------------------------------------------------------------------
def _build_adjacency(edges: list[tuple[int, int]]) -> dict[int, list[int]]:
"""Build a ``task_id -> [depends_on_task_id, ...]`` adjacency map.
Edge direction follows the dependency: an edge (B, A) means "B depends on A",
so we traverse from a dependent task towards its predecessors. A cycle in
this graph is an unsatisfiable deadlock.
"""
adj: dict[int, list[int]] = {}
for task_id, depends_on in edges:
adj.setdefault(task_id, []).append(depends_on)
return adj
def _find_cycle_from(start: int, adj: dict[int, list[int]]) -> list[int] | None:
"""Iterative DFS from ``start``; return a cycle path if one is reachable.
Returns the node sequence closing the cycle (e.g. ``[A, B, A]``) or None.
Iterative (explicit stack) so a pathological deep graph cannot blow the
Python recursion limit — relevant on the shared prod process.
"""
WHITE, GREY, BLACK = 0, 1, 2
color: dict[int, int] = {}
parent: dict[int, int] = {}
# stack of (node, is_exit): is_exit=True marks the post-visit (color BLACK).
stack: list[tuple[int, bool]] = [(start, False)]
while stack:
node, is_exit = stack.pop()
if is_exit:
color[node] = BLACK
continue
if color.get(node, WHITE) != WHITE:
continue
color[node] = GREY
stack.append((node, True))
for nxt in adj.get(node, []):
c = color.get(nxt, WHITE)
if c == GREY:
# Back-edge -> cycle. Reconstruct path nxt..node via parent.
path = [node]
cur = node
while cur != nxt and cur in parent:
cur = parent[cur]
path.append(cur)
path.reverse()
path.append(nxt)
return path
if c == WHITE:
parent[nxt] = node
stack.append((nxt, False))
return None
def detect_cycle(task_id: int, edges: list[tuple[int, int]] | None = None) -> list[int] | None:
"""Detect a dependency cycle reachable from ``task_id``.
Returns the cycle path (node sequence, first == last) or None when the graph
reachable from ``task_id`` is acyclic. ``edges`` may be injected (unit tests);
otherwise the full declared edge set is read from the DB.
never-raise: any error -> None (do not falsely claim a deadlock on an error).
"""
if task_id is None:
return None
try:
if edges is None:
edges = db.get_dependency_edges()
adj = _build_adjacency(edges)
return _find_cycle_from(task_id, adj)
except Exception:
return None
def find_any_cycle(edges: list[tuple[int, int]] | None = None) -> list[int] | None:
"""Backstop: detect ANY cycle in the whole declared graph.
Used by the reconciler tick to surface a deadlock even when no specific task
is being evaluated. Returns the first cycle found or None. never-raise -> None.
"""
try:
if edges is None:
edges = db.get_dependency_edges()
adj = _build_adjacency(edges)
for node in list(adj.keys()):
cyc = _find_cycle_from(node, adj)
if cyc:
return cyc
return None
except Exception:
return None
def _work_item_id_for(task_id: int) -> str | None:
"""Best-effort ``tasks.work_item_id`` lookup for a task_id (never-raise)."""
try:
conn = db.get_db()
try:
row = conn.execute(
"SELECT work_item_id FROM tasks WHERE id = ?", (task_id,)
).fetchone()
finally:
conn.close()
return row[0] if row and row[0] else None
except Exception:
return None
def handle_cycle(cycle: list[int]) -> bool:
"""Escalate a detected dependency cycle: Blocked + alert (B-3, AC-G1).
For every task in the cycle, sets its Plane issue to Blocked (best-effort)
and sends ONE Telegram alert naming the cycle, so a deadlock is visible
instead of a silent forever-wait. Does NOT mutate job_deps / stages — the
declaration is the human's to fix. never-raise: any notify/Plane error is
swallowed; the worker/reconciler never crashes. Returns True if an alert was
attempted, False on a no-op / error.
"""
if not cycle:
return False
try:
# Map task ids -> work-item ids for a human-readable chain.
labels: list[str] = []
seen: set[int] = set()
for tid in cycle:
wi = _work_item_id_for(tid)
labels.append(wi or f"task#{tid}")
if tid not in seen:
seen.add(tid)
chain = " -> ".join(labels)
try:
from . import notifications, plane_sync
except Exception:
return False
# Blocked indication on each distinct issue in the cycle.
for tid in seen:
wi = _work_item_id_for(tid)
if wi:
try:
plane_sync.set_issue_blocked(wi)
except Exception:
pass
try:
notifications.send_telegram(
f"\U0001f6a8 ORCH-026: dependency DEADLOCK detected (cycle): {chain}. "
f"Tasks set to Blocked — fix the blocked-by declaration."
)
except Exception:
pass
logger.error("ORCH-026: dependency cycle detected: %s", chain)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Declaration (B-1) — db.add_dependency + immediate cycle escalation
# ---------------------------------------------------------------------------
def declare_dependency(task_id: int, depends_on_task_id: int) -> bool:
"""Declare "task_id (B) blocked-by depends_on_task_id (A)" and check for a cycle.
Thin wrapper over ``db.add_dependency`` that, after a successful insert, runs
``detect_cycle`` from the new dependent — so a freshly-introduced deadlock is
surfaced (Blocked + alert) at declaration time (best UX, ADR B-3) rather than
only by the reconciler backstop. The edge is NOT rolled back on a cycle (the
SQL gate already keeps the cyclic tasks un-claimable; the human fixes the
declaration) — we make it VISIBLE. never-raise: any error -> False.
Returns True iff a NEW edge row was inserted (idempotent re-declaration ->
False, matching db.add_dependency).
"""
try:
inserted = db.add_dependency(task_id, depends_on_task_id)
# Always check for a cycle (even on a duplicate edge an existing cycle may
# now be relevant), but only escalate when one is actually found.
cyc = detect_cycle(task_id)
if cyc:
handle_cycle(cyc)
return inserted
except Exception:
return False
def ingest_plane_relations(
task_id: int, issue_id: str, project_id: str
) -> int:
"""B-1 (plane/hybrid source): import Plane ``blocked-by`` relations into job_deps.
Reads the issue's ``blocked_by`` predecessors from Plane, resolves each to a
local ``tasks.id`` (intra-repo only, v1) and declares the edge. A predecessor
not yet known locally (no task row) is SKIPPED — the scheduler can only gate
on tasks it knows; a re-run after that task is created will pick it up.
Active ONLY when ``task_deps_source`` is ``plane`` or ``hybrid`` (default
``db`` -> no Plane call on the hot creation path). never-raise (self-hosting):
any error -> 0 edges, the pipeline start is never blocked by Plane. Returns
the number of edges declared.
"""
source = (getattr(settings, "task_deps_source", "db") or "db").strip().lower()
if source not in ("plane", "hybrid"):
return 0
if not issue_id or not project_id:
return 0
try:
from . import plane_sync
blocked_by = plane_sync.fetch_blocked_by_issue_ids(issue_id, project_id)
except Exception:
return 0
declared = 0
for pred_issue in blocked_by:
try:
pred = db.get_task_by_plane_id(str(pred_issue))
if not pred:
continue
if declare_dependency(task_id, pred["id"]):
declared += 1
except Exception:
continue
return declared
# ---------------------------------------------------------------------------
# Observability (/queue snapshot, G-2)
# ---------------------------------------------------------------------------
def snapshot() -> dict:
"""Read-only summary of the dependency subsystem for GET /queue (G-2).
Returns a dict (NOT a source of truth — pure observability):
* ``enabled`` — task_deps_enabled flag;
* ``source`` — task_deps_source (db|plane|hybrid);
* ``edges`` — number of declared edges;
* ``blocked_tasks`` — list of ``{task_id, work_item_id, waiting_on}`` for
tasks with at least one un-done predecessor;
* ``cycle`` — a detected cycle path (work-item labels) or None.
never-raise: any error -> a minimal dict with the flags and empty data.
"""
enabled = bool(getattr(settings, "task_deps_enabled", False))
source = getattr(settings, "task_deps_source", "db")
try:
edges = db.get_dependency_edges()
blocked: list[dict] = []
for task_id in {e[0] for e in edges}:
ready, waiting_on = is_task_ready(task_id)
if not ready:
blocked.append({
"task_id": task_id,
"work_item_id": _work_item_id_for(task_id),
"waiting_on": waiting_on,
})
cyc = find_any_cycle(edges)
cycle_labels = None
if cyc:
cycle_labels = [(_work_item_id_for(t) or f"task#{t}") for t in cyc]
return {
"enabled": enabled,
"source": source,
"edges": len(edges),
"blocked_tasks": blocked,
"cycle": cycle_labels,
}
except Exception:
return {
"enabled": enabled,
"source": source,
"edges": 0,
"blocked_tasks": [],
"cycle": None,
}

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
"""ORCH-026 conditionality / self-hosting safety (TC-A06, TC-A07).
TC-A06 kill-switch / out-of-scope: with the flag off (or for a repo outside the
merge-gate scope) the merge path behaves 1:1 as before ORCH-026 — no-op.
TC-A07 self-hosting safety: the new Level-A logic never pushes to main; the only
force op stays --force-with-lease on the task branch; STAGE_TRANSITIONS
and the QG_CHECKS registry are unchanged.
"""
import os
import tempfile
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_cond.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
# ----------------------------------------------------------------- TC-A06
def test_out_of_scope_repo_is_noop_even_with_flag_on(monkeypatch):
"""A repo outside merge_gate scope -> N/A pass, regardless of premerge flag."""
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "orchestrator", raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
# enduro-trails is NOT in the scope -> no lease, no rebase, just N/A.
called = {"acquire": 0, "rebase": 0}
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
lambda *a, **k: (called.__setitem__("acquire", called["acquire"] + 1), (True, "x"))[1],
raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main",
lambda *a, **k: (called.__setitem__("rebase", called["rebase"] + 1), (True, "x"))[1],
raising=False)
ok, reason = checks.check_branch_mergeable("enduro-trails", "ET-1", "feature/e")
assert ok is True
assert "N/A" in reason
assert called["acquire"] == 0 and called["rebase"] == 0
def test_task_deps_kill_switch_omits_gate(monkeypatch):
"""task_deps_enabled=False -> claim_next_job query is the ORCH-1 query (no gate)."""
import src.db as db
monkeypatch.setattr(db.settings, "task_deps_enabled", False, raising=False)
# Inspect the SQL the claim builds by stubbing the connection.
captured = {}
class _FakeConn:
def execute(self, sql, *a):
captured.setdefault("sql", sql)
class _R:
def fetchone(self_inner):
return None
return _R()
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
db.claim_next_job()
assert "NOT EXISTS" not in captured["sql"], "gate must be omitted when disabled"
def test_task_deps_enabled_adds_gate(monkeypatch):
import src.db as db
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
captured = {}
class _FakeConn:
def execute(self, sql, *a):
captured.setdefault("sql", sql)
class _R:
def fetchone(self_inner):
return None
return _R()
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
db.claim_next_job()
assert "NOT EXISTS" in captured["sql"], "gate must be present when enabled"
assert "job_deps" in captured["sql"]
# ----------------------------------------------------------------- TC-A07
def test_stage_transitions_unchanged():
"""ORCH-026 must not touch the state machine (AC-A5)."""
from src.stages import STAGE_TRANSITIONS
# The canonical happy-path edges must still exist exactly.
assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy"
assert STAGE_TRANSITIONS["deploy"]["next"] == "done"
assert STAGE_TRANSITIONS["development"]["next"] == "review"
def test_qg_registry_has_no_new_dep_gate():
"""The dependency gate is врезка in claim_next_job, NOT a registered QG."""
from src.qg.checks import QG_CHECKS
joined = " ".join(QG_CHECKS.keys())
assert "task_dep" not in joined and "dependency" not in joined
def test_premerge_only_force_with_lease_on_branch():
"""auto_rebase_onto_main never pushes to main; force is --force-with-lease only."""
import inspect
src = inspect.getsource(merge_gate.auto_rebase_onto_main)
assert "--force-with-lease" in src
# No raw 'push origin main' / force-push to main in the rebase path.
assert "push origin main" not in src
assert "--force " not in src # plain --force (not -with-lease) is forbidden

View File

@@ -0,0 +1,136 @@
"""ORCH-026 Level B — dependency cycle / deadlock detection (TC-B03, TC-B04).
TC-B03 detect_cycle is deterministic: A->B->A (and longer) is detected; an
acyclic graph yields None. Pure function (edges injected).
TC-B04 a detected cycle escalates: set_issue_blocked + a Telegram alert, with
no worker crash and no blocking of other tasks (never-raise).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_cycles.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import task_deps # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "cycles.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
init_db()
yield
# ----------------------------------------------------------------- TC-B03
def test_detect_two_node_cycle():
# edge (B, A) means "B depends on A"; 1->2 and 2->1 is a 2-cycle.
edges = [(1, 2), (2, 1)]
cyc = task_deps.detect_cycle(1, edges=edges)
assert cyc is not None
assert cyc[0] == cyc[-1] # closed cycle
assert set(cyc) == {1, 2}
def test_detect_longer_cycle():
edges = [(1, 2), (2, 3), (3, 1)]
cyc = task_deps.detect_cycle(1, edges=edges)
assert cyc is not None
assert set(cyc) >= {1, 2, 3}
def test_acyclic_graph_has_no_cycle():
edges = [(1, 2), (2, 3), (1, 3)] # DAG
assert task_deps.detect_cycle(1, edges=edges) is None
assert task_deps.find_any_cycle(edges=edges) is None
def test_find_any_cycle_scans_whole_graph():
# A disconnected cycle 10<->11 not reachable from node 1.
edges = [(1, 2), (10, 11), (11, 10)]
assert task_deps.detect_cycle(1, edges=edges) is None
cyc = task_deps.find_any_cycle(edges=edges)
assert cyc is not None
assert set(cyc) == {10, 11}
def test_detect_cycle_never_raises_on_garbage():
assert task_deps.detect_cycle(None) is None
# Malformed edge list -> swallowed -> None.
assert task_deps.detect_cycle(1, edges="not-a-list") is None
# ----------------------------------------------------------------- TC-B04
def _make_task(work_item_id, stage="development"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def test_handle_cycle_blocks_and_alerts(monkeypatch):
a = _make_task("ORCH-60")
b = _make_task("ORCH-61")
db.add_dependency(a, b)
db.add_dependency(b, a) # cycle a<->b
blocked = []
alerts = []
import src.plane_sync as plane_sync
import src.notifications as notifications
monkeypatch.setattr(plane_sync, "set_issue_blocked",
lambda wi, *a, **k: blocked.append(wi), raising=False)
monkeypatch.setattr(notifications, "send_telegram",
lambda text, *a, **k: alerts.append(text), raising=False)
cyc = task_deps.detect_cycle(a)
assert cyc is not None
ok = task_deps.handle_cycle(cyc)
assert ok is True
assert set(blocked) == {"ORCH-60", "ORCH-61"}
assert len(alerts) == 1
assert "ORCH-60" in alerts[0] and "ORCH-61" in alerts[0]
def test_handle_cycle_never_raises_when_notify_fails(monkeypatch):
a = _make_task("ORCH-70")
b = _make_task("ORCH-71")
db.add_dependency(a, b)
db.add_dependency(b, a)
import src.plane_sync as plane_sync
import src.notifications as notifications
def _boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync, "set_issue_blocked", _boom, raising=False)
monkeypatch.setattr(notifications, "send_telegram", _boom, raising=False)
cyc = task_deps.detect_cycle(a)
# Must not propagate the exception (AC-G1).
assert task_deps.handle_cycle(cyc) in (True, False)
def test_declare_dependency_escalates_cycle(monkeypatch):
"""declare_dependency surfaces a freshly-introduced cycle at declaration."""
a = _make_task("ORCH-80")
b = _make_task("ORCH-81")
handled = []
monkeypatch.setattr(task_deps, "handle_cycle",
lambda cyc: handled.append(cyc), raising=False)
assert task_deps.declare_dependency(a, b) is True
assert handled == [] # no cycle yet
# Closing the loop -> handle_cycle invoked.
assert task_deps.declare_dependency(b, a) is True
assert len(handled) == 1

View File

@@ -0,0 +1,79 @@
"""ORCH-026 Level B — blocked-task visibility (TC-B06).
A dep-blocked task surfaces a waiting-line ("⏳ ждёт ORCH-NNN") in its single
Telegram tracker card; the "one card per task" invariant is preserved (the line
is added to the SAME render, not a new message). Render is never broken by the
dependency lookup (never-raise).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_visibility.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import notifications # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "vis.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
init_db()
yield
def _make_task(work_item_id, stage="development"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
"VALUES (?, ?, ?, ?, ?, ?)",
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}",
stage, f"title {work_item_id}"),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def test_blocked_task_shows_waiting_line():
a = _make_task("ORCH-90", stage="development")
b = _make_task("ORCH-91", stage="development")
db.add_dependency(b, a)
text = notifications.render_task_tracker(b)
assert "ждёт" in text
assert "ORCH-90" in text
def test_ready_task_has_no_waiting_line():
a = _make_task("ORCH-92", stage="done")
b = _make_task("ORCH-93", stage="development")
db.add_dependency(b, a)
text = notifications.render_task_tracker(b)
assert "ждёт" not in text
def test_done_task_has_no_waiting_line():
a = _make_task("ORCH-94", stage="development")
b = _make_task("ORCH-95", stage="done")
db.add_dependency(b, a)
text = notifications.render_task_tracker(b)
# A done task is terminal -> the waiting-line branch is skipped entirely.
assert "ждёт" not in text
def test_render_never_raises_on_dep_error(monkeypatch):
b = _make_task("ORCH-96", stage="development")
from src import task_deps
monkeypatch.setattr(task_deps, "is_task_ready",
lambda tid: (_ for _ in ()).throw(RuntimeError("boom")),
raising=False)
# Must still produce a card (no crash).
text = notifications.render_task_tracker(b)
assert "ORCH-96" in text

View File

@@ -0,0 +1,124 @@
"""ORCH-026 Level B — declarative dependencies integration (TC-B08).
End-to-end (DB level): B declared blocked-by A; queued B does not start until A
is 'done'; after A->done the worker can claim B. Also covers the plane/hybrid
ingestion path: Plane `blocked-by` relations are resolved to local task ids and
written into job_deps (the scheduler then reads only the DB).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_depsint.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
from src import task_deps # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "depsint.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
init_db()
yield
def _make_task(work_item_id, stage="development", plane_id=None):
conn = get_db()
pid = plane_id or work_item_id
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
"VALUES (?, ?, ?, ?, ?, ?)",
(pid, work_item_id, "orchestrator", f"feature/{work_item_id}", stage, pid),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _set_stage(task_id, stage):
conn = get_db()
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
conn.commit()
conn.close()
def test_b_waits_for_a_then_runs():
a = _make_task("ORCH-200", stage="development")
b = _make_task("ORCH-201", stage="development")
db.add_dependency(b, a)
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
# While A is in flight, B is not claimable.
assert claim_next_job() is None
ready, waiting = task_deps.is_task_ready(b)
assert ready is False and "ORCH-200" in waiting
# A advances through to done.
_set_stage(a, "review")
assert claim_next_job() is None # still not terminal
_set_stage(a, "done")
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
def test_multiple_predecessors_all_must_be_done():
a1 = _make_task("ORCH-210", stage="development")
a2 = _make_task("ORCH-211", stage="development")
b = _make_task("ORCH-212", stage="development")
db.add_dependency(b, a1)
db.add_dependency(b, a2)
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b)
_set_stage(a1, "done")
assert claim_next_job() is None, "still blocked by a2"
_set_stage(a2, "done")
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
# ---- plane/hybrid ingestion path (TC-B01) ---------------------------------
def test_ingest_plane_relations_writes_db(monkeypatch):
monkeypatch.setattr(db.settings, "task_deps_source", "hybrid", raising=False)
a = _make_task("ORCH-220", stage="development", plane_id="plane-uuid-A")
b = _make_task("ORCH-221", stage="development", plane_id="plane-uuid-B")
import src.plane_sync as plane_sync
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
lambda issue_id, project_id, **k: ["plane-uuid-A"],
raising=False)
n = task_deps.ingest_plane_relations(b, "plane-uuid-B", "proj-1")
assert n == 1
assert db.get_dependencies(b) == [a]
def test_ingest_noop_when_source_db(monkeypatch):
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
b = _make_task("ORCH-230", stage="development", plane_id="plane-uuid-Z")
import src.plane_sync as plane_sync
called = {"n": 0}
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
lambda *a, **k: called.__setitem__("n", called["n"] + 1) or [],
raising=False)
n = task_deps.ingest_plane_relations(b, "plane-uuid-Z", "proj-1")
assert n == 0
assert called["n"] == 0, "default db source must not call Plane"
def test_ingest_never_raises_on_plane_outage(monkeypatch):
monkeypatch.setattr(db.settings, "task_deps_source", "plane", raising=False)
b = _make_task("ORCH-240", stage="development", plane_id="plane-uuid-Y")
import src.plane_sync as plane_sync
def _boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids", _boom, raising=False)
assert task_deps.ingest_plane_relations(b, "plane-uuid-Y", "proj-1") == 0

View File

@@ -0,0 +1,95 @@
"""ORCH-026 Level A serialization (TC-A02..A05).
The merge-lease window (ORCH-043/065) is what serialises "merge -> main-updated"
per repo; ORCH-026 reuses it unchanged. These tests confirm the properties the
ADR relies on:
TC-A02 extended window: while A holds the lease, B of the SAME repo gets
"merge-lock busy" -> defer (not rollback); holder-aware release does
NOT delete A's lease.
TC-A03 strict per-repo: an orchestrator lease never blocks an enduro-trails
acquire (both claimable in parallel).
TC-A04 restart-safe + proactive reclaim: a dead holder's lease is reclaimed
(reclaim_stale_lease) so the pipeline never wedges forever.
TC-A05 anti-livelock defer budget: merge_defer_max_attempts is bounded and
positive -> exhaustion escalates instead of looping forever.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serialize.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
@pytest.fixture
def leases_dir(tmp_path, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "", raising=False)
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True, raising=False)
return tmp_path
# ----------------------------------------------------------------- TC-A02
def test_second_task_same_repo_defers_not_rollback(leases_dir):
okA, reasonA = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
assert okA is True
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is False
assert reasonB == "merge-lock busy" # -> caller DEFERS, never a rollback signal
def test_holder_aware_release_keeps_foreign_lease(leases_dir):
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
# A delayed release from B (which never held it) must NOT delete A's lease.
merge_gate.release_merge_lease("orchestrator", "feature/B")
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is False and reasonB == "merge-lock busy"
# A's own release frees it.
merge_gate.release_merge_lease("orchestrator", "feature/A")
okB2, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB2 is True
# ----------------------------------------------------------------- TC-A03
def test_serialization_is_strictly_per_repo(leases_dir):
okA, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
okET, _ = merge_gate.acquire_merge_lease("enduro-trails", "feature/E", "ET-1")
assert okA is True
assert okET is True, "a different repo must be claimable in parallel (AC-A3)"
# ----------------------------------------------------------------- TC-A04
def test_dead_holder_lease_is_reclaimed(leases_dir, monkeypatch):
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
# Holder pid is THIS process; simulate it being dead.
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False, raising=False)
reclaimed = merge_gate.reclaim_stale_lease("orchestrator")
assert reclaimed is True
# After reclaim B can acquire -> pipeline does not wedge forever.
okB, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is True
def test_stale_lease_age_reclaimed_on_acquire(leases_dir, monkeypatch):
# A very short timeout makes the existing lease look stale on B's acquire.
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 0, raising=False)
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
assert okB is True
assert "reclaimed" in reasonB
# ----------------------------------------------------------------- TC-A05
def test_defer_budget_is_bounded(monkeypatch):
"""The defer budget is a positive finite int -> exhaustion escalates (AC-A6)."""
from src.config import settings
assert isinstance(settings.merge_defer_max_attempts, int)
assert settings.merge_defer_max_attempts > 0
assert settings.merge_defer_delay_s > 0

View File

@@ -0,0 +1,83 @@
"""ORCH-026 — additive job_deps migration (TC-G01, AC-G4).
The migration must be additive (CREATE TABLE/INDEX IF NOT EXISTS), idempotent,
and safe on a pre-existing DB with data: existing columns of jobs/tasks/
agent_runs/events are untouched.
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_migration.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db # noqa: E402
@pytest.fixture
def dbfile(tmp_path, monkeypatch):
f = tmp_path / "mig.db"
monkeypatch.setattr(db.settings, "db_path", str(f))
return f
def _columns(conn, table):
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
def test_job_deps_table_created(dbfile):
init_db()
conn = get_db()
cols = _columns(conn, "job_deps")
conn.close()
assert set(cols) == {"task_id", "depends_on_task_id", "created_at"}
def test_job_deps_indices_created(dbfile):
init_db()
conn = get_db()
idx = {r[1] for r in conn.execute("PRAGMA index_list(job_deps)").fetchall()}
conn.close()
assert "idx_job_deps_task" in idx
assert "idx_job_deps_depends" in idx
def test_primary_key_idempotent_insert(dbfile):
init_db()
conn = get_db()
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
conn.commit()
n = conn.execute("SELECT COUNT(*) FROM job_deps").fetchone()[0]
conn.close()
assert n == 1, "PK (task_id, depends_on_task_id) prevents dup rows"
def test_migration_idempotent_and_preserves_data(dbfile):
# First init + seed legacy data.
init_db()
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES ('ET-1','ET-1','enduro-trails','feature/x','development')"
)
conn.execute(
"INSERT INTO jobs (agent, repo, status) VALUES ('developer','enduro-trails','queued')"
)
conn.commit()
tasks_cols_before = _columns(conn, "tasks")
jobs_cols_before = _columns(conn, "jobs")
conn.close()
# Re-run init_db (simulates a restart on a live DB) -> must be a no-op.
init_db()
conn = get_db()
assert _columns(conn, "tasks") == tasks_cols_before, "tasks columns unchanged"
assert _columns(conn, "jobs") == jobs_cols_before, "jobs columns unchanged"
# Legacy data survives.
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
conn.close()

View File

@@ -0,0 +1,82 @@
"""ORCH-026 Level A (TC-A01): proactive pre-merge rebase.
check_branch_mergeable must ALWAYS rebase the task branch onto the current
origin/main under the held merge-lease when ``premerge_rebase_always`` is on —
even when ``branch_is_behind_main`` would short-circuit (no conflict, formally
not behind). With the flag OFF the ORCH-043 short-circuit is restored 1:1.
These are pure unit tests: every merge_gate primitive is monkeypatched, so no
git/network is touched — we assert the CONTROL FLOW (was auto_rebase_onto_main
called?) and the verdict.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_premerge.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
@pytest.fixture
def patched_gate(monkeypatch):
"""Patch merge_gate primitives; record whether auto_rebase ran."""
calls = {"rebase": 0, "retest": 0, "released": 0, "behind_checked": 0}
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
lambda *a, **k: (True, "lease acquired"), raising=False)
def _behind(repo, branch):
calls["behind_checked"] += 1
return False # NOT behind -> ORCH-043 would short-circuit
def _rebase(repo, branch):
calls["rebase"] += 1
return True, "rebased (noop)"
def _retest(repo, branch):
calls["retest"] += 1
return True, "green"
def _release(repo, branch=None):
calls["released"] += 1
monkeypatch.setattr(merge_gate, "branch_is_behind_main", _behind, raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
monkeypatch.setattr(merge_gate, "retest_branch", _retest, raising=False)
monkeypatch.setattr(merge_gate, "release_merge_lease", _release, raising=False)
return calls
def test_always_rebases_even_when_not_behind(patched_gate, monkeypatch):
"""premerge_rebase_always=True -> auto_rebase_onto_main ALWAYS called (AC-A2)."""
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
assert ok is True
assert patched_gate["rebase"] == 1, "rebase must run even when not behind"
assert patched_gate["retest"] == 1, "re-test must run after the proactive rebase"
def test_flag_off_short_circuits_like_orch043(patched_gate, monkeypatch):
"""premerge_rebase_always=False -> not-behind short-circuit, no rebase (AC-A7)."""
monkeypatch.setattr(checks.settings, "premerge_rebase_always", False, raising=False)
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
assert ok is True
assert reason == "branch up-to-date with main"
assert patched_gate["rebase"] == 0, "must NOT rebase when not behind and flag off"
def test_disabled_gate_is_noop(monkeypatch):
"""merge_gate_enabled=False -> pass-through, no lease/rebase at all (AC-G2)."""
monkeypatch.setattr(checks.settings, "merge_gate_enabled", False, raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
assert ok is True
assert "disabled" in reason

View File

@@ -0,0 +1,90 @@
"""ORCH-026 — /queue task_deps observability (TC-G02, G-2).
task_deps.snapshot() is a read-only summary (NOT a source of truth) exposing the
declared edges, blocked tasks and any detected cycle. It must never raise.
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_queue_obs.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import task_deps # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "obs.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
init_db()
yield
def _make_task(work_item_id, stage="development"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def test_snapshot_shape_empty():
snap = task_deps.snapshot()
assert snap["enabled"] is True
assert snap["source"] == "db"
assert snap["edges"] == 0
assert snap["blocked_tasks"] == []
assert snap["cycle"] is None
def test_snapshot_reports_blocked_task():
a = _make_task("ORCH-100", stage="development")
b = _make_task("ORCH-101", stage="development")
db.add_dependency(b, a)
snap = task_deps.snapshot()
assert snap["edges"] == 1
assert len(snap["blocked_tasks"]) == 1
bt = snap["blocked_tasks"][0]
assert bt["work_item_id"] == "ORCH-101"
assert "ORCH-100" in bt["waiting_on"]
assert snap["cycle"] is None
def test_snapshot_reports_cycle():
a = _make_task("ORCH-102")
b = _make_task("ORCH-103")
db.add_dependency(a, b)
db.add_dependency(b, a)
snap = task_deps.snapshot()
assert snap["cycle"] is not None
assert "ORCH-102" in snap["cycle"] or "ORCH-103" in snap["cycle"]
def test_snapshot_never_raises(monkeypatch):
monkeypatch.setattr(db, "get_dependency_edges",
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
raising=False)
snap = task_deps.snapshot()
assert snap["edges"] == 0
assert snap["blocked_tasks"] == []
def test_queue_endpoint_includes_task_deps(monkeypatch):
"""GET /queue payload carries the task_deps block (read-only)."""
import asyncio
from src import main
payload = asyncio.run(main.queue())
assert "task_deps" in payload
assert "enabled" in payload["task_deps"]

View File

@@ -0,0 +1,65 @@
"""ORCH-026 Level A — serialization integration (TC-A08).
Scenario (no network, lease + gate level): two tasks of the SAME repo race for
the merge edge. While A holds the merge-lease (the merge->main-updated window),
B's check_branch_mergeable returns "merge-lock busy" -> the engine DEFERS B (it
does NOT roll back). After A releases (A reached main / done), B acquires, is
proactively rebased onto the now-current main (carrying A's code) and merges.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serint.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
@pytest.fixture
def env(tmp_path, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
# Make the git/test primitives deterministic no-ops; A's rebase is a no-op,
# B's rebase is the real "catch up to A's code".
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False, raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", lambda r, b: (True, "ok"), raising=False)
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "green"), raising=False)
return tmp_path
def test_serialized_merge_window(env, monkeypatch):
repo = "orchestrator"
# A reaches the merge edge first: gate passes and HOLDS the lease.
okA, reasonA = checks.check_branch_mergeable(repo, "ORCH-1", "feature/A")
assert okA is True
# Lease is held by A.
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
# B reaches the merge edge while A still holds the window -> busy -> DEFER.
okB, reasonB = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
assert okB is False
assert reasonB == "merge-lock busy" # NOT a rollback; engine re-queues via available_at
# B's defer must NOT have stolen / cleared A's lease.
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
# A completes (PR merged / deploy->done) -> lease released.
merge_gate.release_merge_lease(repo, "feature/A")
# B retries: now acquires, is proactively rebased onto current main, merges.
rebased = {"called": 0}
def _rebase(r, b):
rebased["called"] += 1
return True, "rebased onto A"
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
okB2, reasonB2 = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
assert okB2 is True
assert rebased["called"] == 1, "B must be proactively rebased onto the fresh main (A's code)"

View File

@@ -0,0 +1,157 @@
"""ORCH-026 Level B — declarative task dependencies (TC-B01/B02/B05/B07).
Real SQLite (tmp db). We drive tasks + job_deps directly and assert:
TC-B01 add_dependency declares an edge; get_dependencies resolves it; a
self-edge is rejected; never-raise on a bad input.
TC-B02 is_task_ready: a task with an un-done predecessor is NOT ready; when
every predecessor reaches 'done' it becomes ready.
TC-B05 claim_next_job does NOT claim a dep-blocked job (no slot taken); once
the predecessor is 'done' the job becomes claimable.
TC-B07 reconciler skip helper: is_task_ready=False is honoured (the gate task
is left waiting).
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_task_deps.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
from src import task_deps # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "deps.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
init_db()
yield
def _make_task(stage="development", work_item_id="ORCH-1", repo="orchestrator"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _set_stage(task_id, stage):
conn = get_db()
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
conn.commit()
conn.close()
# ----------------------------------------------------------------- TC-B01
def test_add_dependency_declares_and_resolves():
a = _make_task(work_item_id="ORCH-10", stage="development")
b = _make_task(work_item_id="ORCH-11", stage="development")
assert db.add_dependency(b, a) is True
assert db.get_dependencies(b) == [a]
# Idempotent: re-declaring the same edge is a no-op.
assert db.add_dependency(b, a) is False
def test_self_edge_rejected():
a = _make_task(work_item_id="ORCH-12")
assert db.add_dependency(a, a) is False
assert db.get_dependencies(a) == []
def test_add_dependency_never_raises_on_bad_input():
assert db.add_dependency(None, 1) is False
assert db.add_dependency(1, None) is False
# ----------------------------------------------------------------- TC-B02
def test_is_task_ready_blocked_then_ready():
a = _make_task(work_item_id="ORCH-20", stage="development")
b = _make_task(work_item_id="ORCH-21", stage="development")
db.add_dependency(b, a)
ready, waiting = task_deps.is_task_ready(b)
assert ready is False
assert "ORCH-20" in waiting
_set_stage(a, "done")
ready2, waiting2 = task_deps.is_task_ready(b)
assert ready2 is True
assert waiting2 == []
def test_is_task_ready_no_deps_is_ready():
a = _make_task(work_item_id="ORCH-22")
ready, waiting = task_deps.is_task_ready(a)
assert ready is True and waiting == []
# ----------------------------------------------------------------- TC-B05
def test_claim_skips_dep_blocked_job():
a = _make_task(work_item_id="ORCH-30", stage="development")
b = _make_task(work_item_id="ORCH-31", stage="development")
db.add_dependency(b, a)
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
# B is blocked by un-done A -> claim must NOT pick it (no slot taken).
claimed = claim_next_job()
assert claimed is None, "dep-blocked job must not be claimed"
# A finishes -> B becomes claimable.
_set_stage(a, "done")
claimed2 = claim_next_job()
assert claimed2 is not None
assert claimed2["id"] == job_b
def test_claim_prefers_unblocked_job_over_blocked():
a = _make_task(work_item_id="ORCH-40", stage="development")
b = _make_task(work_item_id="ORCH-41", stage="development")
c = _make_task(work_item_id="ORCH-42", stage="development")
db.add_dependency(b, a) # b blocked by a
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b) # older id
job_c = enqueue_job("developer", "orchestrator", "C", task_id=c) # not blocked
claimed = claim_next_job()
assert claimed is not None
assert claimed["id"] == job_c, "blocked B skipped, unblocked C claimed"
assert job_b # referenced
# ----------------------------------------------------------------- TC-B07
def test_reconciler_skip_helper_honours_block(monkeypatch):
"""The reconciler reads is_task_ready; a not-ready task must be skipped."""
from src import reconciler as rec
a = _make_task(work_item_id="ORCH-50", stage="development")
b = _make_task(work_item_id="ORCH-51", stage="development")
db.add_dependency(b, a)
advanced = {"called": False}
monkeypatch.setattr(rec, "advance_if_gate_passed",
lambda *a, **k: advanced.__setitem__("called", True),
raising=False)
monkeypatch.setattr(rec, "has_active_job_for_task", lambda tid: False, raising=False)
monkeypatch.setattr(rec, "developer_retry_count", lambda tid: 0, raising=False)
monkeypatch.setattr(rec.settings, "task_deps_enabled", True, raising=False)
monkeypatch.setattr(rec.settings, "reconcile_enabled", True, raising=False)
monkeypatch.setattr(rec.settings, "reconcile_grace_default_s", 0, raising=False)
r = rec.Reconciler()
# Bypass Guard 2 (networked) so we isolate Guard 3.
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda task: False)
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}
r._reconcile_gate_task(task_row)
assert advanced["called"] is False, "dep-blocked task must not be advanced (B-5)"

View File

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