diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 89b6955..4d5cf3a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -10,7 +10,7 @@ - **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`. - **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`. - **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. -- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. +- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts/.merge-lease-.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД. +- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`/.merge-lease-.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД. **ORCH-026 (A-1):** это окно = «merge → main-updated» (для self `done` ⇔ SHA-in-main, ORCH-073) — пока A не в `main`, B того же репо получает `merge-lock busy` → defer. Окно сериализации per-repo НЕ переписывается; кросс-репо параллелизм сохранён (лиз — per-repo файл). - **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**. Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`. +Безусловный pre-merge rebase + связь с зависимостями задач — [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md) (ORCH-026). + +### Зависимости задач: B ждёт A (ORCH-026, Уровень B) +Плоская очередь ORCH-1 (FIFO по `id` + `available_at` + `max_concurrency`) не выражала логических зависимостей. ORCH-026 вводит декларативные связи «задача B не стартует, пока не готовы её depends-on» — без новой стадии и без изменения `STAGE_TRANSITIONS`/`QG_CHECKS`. +- **Источник истины планировщика — БД** (аддитивная таблица `job_deps(task_id, depends_on_task_id)`): claim в горячем цикле обслуживает очередь ВСЕХ проектов и обязан быть offline-устойчив (сетевой Plane на каждый claim = встанет очередь всех проектов). Источник **декларации** настраивается `task_deps_source = db|plane|hybrid` (дефолт `db`; `plane`/`hybrid` читают Plane relations в `handle_work_item_created` и кэшируют в `job_deps`). +- **Гейт планировщика (`claim_next_job`)** — условие `NOT EXISTS (job_deps d JOIN tasks t … WHERE d.task_id=j.task_id AND t.stage!='done')` при `task_deps_enabled`: задача с незавершённой зависимостью **не выбирается** (агент не запускается, слот `max_concurrency` не занимается). Инертно при пустой `job_deps` → нулевая регрессия; kill-switch `task_deps_enabled=False` → запрос 1:1 как ORCH-1. +- **Детект дедлоков** — DFS-цикл-детектор (leaf `src/task_deps.py::detect_cycle`) при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert (Telegram/Plane) с перечислением цикла. Поток остальных задач не блокируется. +- **Видимость** — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (`update_task_tracker`, never-raise); Plane `Blocked` — на дедлоке (не на нормальном коротком ожидании, чтобы не флаппить). Инвариант «одна карточка на задачу» сохранён. +- **Совместимость:** `reconciler` F-1 пропускает dep-заблокированные задачи (`is_task_ready`, паттерн ORCH-060); `reaper` сканирует только `running` → dep-блок остаётся `queued`, не трогается. Зависимости — только intra-repo (v1). +- **Наблюдаемость:** блок `task_deps` в `GET /queue` (заблокированные задачи, держатель merge-lease, defer-счётчики, обнаруженные циклы) — read-only. + +Подробнее: [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md), детально — `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`. ### Исполняемый самодеплой стадии `deploy` (ORCH-36) `deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия @@ -452,6 +465,7 @@ Monitoring after Deploy → Done - `tasks` — задачи и их стадии - `agent_runs` — запуски агентов (run_id, usage, cost) - `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом +- `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A» ## Изоляция (git worktree, ORCH-2) Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/`. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index af5d1b2..e496903 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -20,11 +20,12 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0012 | Security-гейт (secrets/deps) | accepted | 2026-06-08 | ORCH-022 | | adr-0013 | Merge-в-main + пост-деплой верификация как условие `done` | accepted | 2026-06-08 | ORCH-071 | | adr-0014 | SHA-в-main — единственный критерий merge-verify + регресс-гард | accepted | 2026-06-08 | ORCH-073 | +| adr-0015 | Зависимости задач (B ждёт A) + сериализация merge внутри репо | accepted | 2026-06-08 | ORCH-026 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0014`). +> свободный номер (текущий максимум — `0015`). > adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»). ## Формат diff --git a/docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md b/docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md new file mode 100644 index 0000000..7e2e7ae --- /dev/null +++ b/docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md @@ -0,0 +1,47 @@ +# adr-0015: Зависимости задач + сериализация merge внутри репо + +**Статус:** accepted · **Дата:** 2026-06-08 · **Источник:** ORCH-026 +**Связи:** дополняет adr-0006 (merge-gate), adr-0011 (merge-lease + reclaim), adr-0013/0014 +(merge-verify, SHA-in-main), adr-0002 (очередь). Детально — +`docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`. + +## Контекст + +Эрозия `main` 08.06 родилась из некоординированного параллелизма задач одного репо (ветки от +устаревшего `main`, фантом-merge затирает соседа). adr-0014 закрыл последствия; ORCH-026 — корень +на уровне планировщика. Плюс исходный скоуп ORCH-026: декларативные зависимости задач (B ждёт A). + +## Решение + +**Уровень A — сериализация merge/деплоя (per-repo).** Окно сериализации уже обеспечивается +merge-lease (adr-0011): захват в `check_branch_mergeable`, удержание до release (PR-merged webhook / +`deploy→done`=SHA-in-main для self / откат / проактивный reclaim). Это и есть окно +«merge → main-updated» — **механизм не переписывается**. Добавляется единственное новое поведение: +**безусловный proactive pre-merge rebase** (флаг `premerge_rebase_always`, дефолт `True`, скоуп +`merge_gate_repos`): под лизом всегда вызывается `auto_rebase_onto_main` (no-op + «Everything +up-to-date» на актуальной ветке → CI не триггерится; реальный догон на отстающей). Инвариант: +никаких push в `main`, force только `--force-with-lease` на ветку. + +**Уровень B — декларативные зависимости.** Аддитивная таблица `job_deps(task_id, +depends_on_task_id)` — **источник истины планировщика** (offline-устойчивость: сетевой Plane в +горячем claim встанет очередью всех проектов). Источник декларации настраивается +`task_deps_source = db|plane|hybrid` (дефолт `db`); планировщик всегда читает БД-кэш. Гейт — +условие `NOT EXISTS` в `claim_next_job` (задача не выбирается, пока есть незавершённая зависимость; +слот `max_concurrency` не занимается). Циклы — DFS-детектор (`src/task_deps.py`) + `set_issue_blocked` ++ alert. Видимость — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (Plane Blocked — на дедлоке). +Зависимости — только intra-repo (v1). + +## Альтернативы + +Отдельный merge-lock/merge-queue (дублирует adr-0011); расширение release-точек лиза (не нужно — +окно уже корректно); Plane как источник истины планировщика (self-hosting risk); гейт зависимостей +в воркере с claim+requeue (churn vs. чистый `NOT EXISTS`); поле в `tasks` вместо таблицы (M:N хуже). + +## Последствия + +Минимально-инвазивно: `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки), переиспользует +merge-gate/merge-lease целиком. Обе фичи инертны без данных → нулевая регрессия для enduro-trails. +restart-safe, never-raise, kill-switch на каждую (`premerge_rebase_always`, `task_deps_enabled`). +Миграция — только аддитивная (`CREATE TABLE/INDEX IF NOT EXISTS`). Ограничение: B v1 — intra-repo. +Self-hosting safety: изменения идут через `deploy-staging` → `Confirm Deploy`, без внеочередного +рестарта прода. diff --git a/docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md b/docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md new file mode 100644 index 0000000..58e3304 --- /dev/null +++ b/docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md @@ -0,0 +1,226 @@ +# ADR-001: Сериализация merge/деплоя внутри репо (A) + декларативные зависимости задач (B) + +**Work Item:** ORCH-026 · **Repo:** orchestrator (self-hosting) · **Стадия:** architecture +**Статус:** Accepted +**Связи:** дополняет ORCH-043 (merge-gate), ORCH-065 (merge-lease + reclaim), ORCH-073/071 +(merge-verify, SHA-in-main), ORCH-1 (очередь). Глобальный ADR — `adr/adr-0015`. + +--- + +## Контекст + +ORCH-026 закрывает **первопричину** эрозии `main` 08.06 (некоординированный параллелизм +задач одного репо: ветки от устаревшего `main`, фантом-merge затирает соседа) и попутно вводит +исходный скоуп — декларативные зависимости задач (B ждёт A). Требования — `01-brd.md`, +`02-trz.md`; PASS/FAIL — `03-acceptance-criteria.md`. + +Ключевое наблюдение архитектора: **бо́льшая часть инфраструктуры для Уровня A уже существует** и +её НЕ нужно переписывать: + +- **merge-lease** (ORCH-065, `src/merge_gate.py`): per-repo файловый лиз + `.merge-lease-.json`, неблокирующий acquire, holder-aware release, проактивный реклейм + мёртвого/устаревшего держателя (`reclaim_stale_lease`, `pid_alive`). Restart-safe, per-repo. +- **merge-gate** (ORCH-043, `check_branch_mergeable`): на ребре `deploy-staging → deploy` + захватывает лиз, при необходимости ребейзит, держит лиз до фактического merge. +- **defer-механизм** (`_handle_merge_gate_defer`): `merge-lock busy` → повторная постановка + deployer'а через `available_at`, bounded `merge_defer_max_attempts` → эскалация (Blocked+alert). +- **окно лиза** уже простирается от `deploy-staging → deploy` до release на одном из событий: + PR-merged webhook (`gitea.py`), `deploy→done` (`stage_engine.py`), откат, проактивный реклейм. + Для self-hosting `done` достигается ТОЛЬКО после `verify_merged_to_main` (SHA-in-main, ORCH-073). + +Таким образом окно сериализации A-1 («merge → main-updated») **структурно уже реализовано**: +пока A не подтверждена в `main` (для self — SHA-in-main → `done`), лиз держится, и B того же +репо на своём merge-gate получает `merge-lock busy` → defer. Открытый вопрос BRD §4.3 (граница +окна для self) решается так: **окно = от acquire до release; release-события не меняем**. Для +non-self репо граница — PR-merged webhook; для self — `deploy→done` (= SHA-in-main подтверждён). + +Что реально **отсутствует** для Уровня A: + +- **A-2: безусловный proactive pre-merge rebase.** Сейчас `check_branch_mergeable` ребейзит + ТОЛЬКО если `branch_is_behind_main` (⇔ `origin/main` не предок HEAD). AC-A2 требует, чтобы + rebase вызывался **всегда** перед merge — детерминированный структурный анти-фантом на уровне + планировщика, не зависящий от точности ancestor-проверки. + +Для Уровня B инфраструктуры нет вовсе: очередь `jobs` (ORCH-1) плоская (FIFO по `id` + +`available_at` + `max_concurrency`), выразить «B ждёт A» нельзя. + +--- + +## Решение + +### Уровень A — сериализация merge/деплоя (минимально-инвазивно, переиспользуя ORCH-043/065) + +**A-1/A-3/A-4 (окно сериализации) — без изменений механизма.** Окно сериализации обеспечивается +существующим merge-lease: захват в `check_branch_mergeable`, удержание до release. Подтверждаем и +фиксируем в доке, что release-события (`PR-merged` / `deploy→done` / откат / `reclaim_stale_lease`) +формируют окно «merge → main-updated». Кросс-репо параллелизм сохранён автоматически (лиз — +per-repo файл). Restart-safe и анти-залипание — за счёт ORCH-065 reclaim. **Кода-изменений нет.** + +**A-2 (безусловный pre-merge rebase) — новое поведение, флаг `premerge_rebase_always`.** + +- В `check_branch_mergeable` (`src/qg/checks.py`), ПОД захваченным merge-lease: когда + `settings.premerge_rebase_always` истинно (и merge-gate применим к репо), **пропустить + short-circuit `branch_is_behind_main`** и **всегда** вызвать `merge_gate.auto_rebase_onto_main`. +- `auto_rebase_onto_main` уже идемпотентен и дёшев на актуальной ветке: `git rebase origin/main` + на не-отстающей ветке — no-op (rc 0, HEAD не меняется), последующий `push --force-with-lease` + → «Everything up-to-date» (тот же SHA, **CI не перезапускается, лишних коммитов нет**). На + отстающей ветке — реальный догон. Текстовый конфликт → существующий контракт: `rebase --abort` + → откат на `development` (как ORCH-043). **Инвариант: никаких push/force-push в `main`** — + единственная force-операция остаётся `--force-with-lease` на ветку задачи. +- Когда флаг выключен → прежнее поведение (ребейз только при `branch_is_behind_main`), + обратная совместимость 1:1 (AC-A7/AC-G2). +- **Скоуп — общий с merge-gate:** реально только для `merge_gate_repos` (пусто → self-hosting + `orchestrator`). Никакого нового scope-флага. + +**A-5/A-6 (safety, anti-livelock) — без изменений.** `STAGE_TRANSITIONS`, `QG_CHECKS`, +`Confirm Deploy` (ORCH-059), exit-коды хука, terminal-sync не трогаются. defer-бюджет — +существующий `merge_defer_max_attempts` → Blocked+alert при исчерпании. Прод-контейнер не +рестартится вне штатного `Confirm Deploy`. + +### Уровень B — декларативные зависимости (новая инфраструктура) + +**B-источник: гибрид с БД как источником истины для планировщика; флаг `task_deps_source`.** + +Планировщик `claim_next_job` — горячий цикл, обслуживающий очередь ВСЕХ проектов из ОДНОГО +инстанса. Он **обязан** быть offline-устойчивым и быстрым: сетевой запрос в Plane на каждый claim += при недоступности Plane встанет конвейер всех проектов (нарушение self-hosting safety). Поэтому: + +- **Авторитетный для планировщика стор — локальная БД**, новая аддитивная таблица + `job_deps(task_id, depends_on_task_id, created_at)` (детали — `08-data-requirements.md`). + Связь хранится по `tasks.id` (стабильный локальный ключ). Зависимости — **только внутри одного + репо** (v1; кросс-репо — non-goal, BRD §5). +- **`task_deps_source = db | plane | hybrid`** (дефолт **`db`**): `db` — связи пишутся напрямую в + `job_deps` (потребитель — декомпозиция эпиков ORCH-025); `plane` — связи читаются из Plane + relations в `handle_work_item_created` и **кэшируются** в `job_deps`; `hybrid` — Plane как + декларация + БД-кэш. Plane-ingestion — тонкий add-on за флагом; планировщик ВСЕГДА читает БД. + +**B-2 (гейт планировщика) — SQL `NOT EXISTS`, без занятия слота `max_concurrency`.** + +Гейт готовности выражается декларативно в `claim_next_job` (`src/db.py`): задача claimable, если +у неё нет ни одной незавершённой зависимости. Когда `settings.task_deps_enabled` — к существующему +SELECT добавляется условие: + +```sql +AND NOT EXISTS ( + SELECT 1 FROM job_deps d + JOIN tasks t ON t.id = d.depends_on_task_id + WHERE d.task_id = j.task_id AND t.stage != 'done' +) +``` + +Это: (1) **не занимает слот** — job просто не выбирается, агент не запускается (AC-B2); +(2) restart-safe (чистая БД); (3) never-raise (это SQL); (4) при пустой `job_deps` — +инертно (нулевая регрессия, AC-G2); (5) при выключенном `task_deps_enabled` условие НЕ +добавляется → запрос 1:1 как в ORCH-1. Как только все зависимости достигают `stage='done'`, +задача автоматически становится claimable. + +Чистая leaf-логика «готова ли задача» выносится в новый модуль `src/task_deps.py`: +`is_task_ready(task_id) -> (bool, waiting_on: list[str])` (never-raise) — для реконсилятора, +карточки и `/queue` (SQL в `claim_next_job` — горячий путь, дублирует ту же семантику). + +**B-3 (детект дедлоков) — DFS, чистая функция.** + +`task_deps.detect_cycle(task_id) -> list[int] | None` — обход графа `job_deps` (внутри репо), +детерминированный, юнит-тестируемый, never-raise. Запускается: (1) при вставке связи +(`add_dependency`) — цикл отклоняется/алертится сразу (лучший UX); (2) backstop-проход в тике +`reconciler` (на случай связей, добавленных в обход). Цикл → `set_issue_blocked(work_item_id)` + +Telegram/Plane alert с перечислением цикла. SQL-гейт B-2 сам по себе никогда не выберет задачу в +цикле (её зависимости не достигнут `done`) — детектор делает это **видимым**, а не молчаливым +вечным ожиданием (AC-B3). Поток остальных задач не блокируется. + +**B-4 (видимость).** + +- Нормальное ожидание (B ждёт A, A в работе — транзиентно и ожидаемо): строка в Telegram-карточке + «⏳ ждёт ORCH-NNN» через `notifications.update_task_tracker`, never-raise/silent. **Plane Blocked + при нормальном ожидании НЕ ставим** — иначе флаппинг Blocked на каждом коротком ожидании. +- Дедлок/цикл (B-3): `set_issue_blocked` (Plane `Blocked`) + alert. Это «и/или» из AC-B4. +- Инвариант «одна карточка на задачу» сохранён (ORCH-042/067). + +**B-5 (совместимость reconciler/reaper).** + +- `reconciler` F-1 не должен «разблокировать» dep-заблокированную задачу мимо её зависимостей. + В фильтр пригодности reconciler добавляется проверка `task_deps.is_task_ready` (по образцу + `reconcile_skip_blocked_enabled`, ORCH-060): не готова → skip. +- `reaper` сканирует **`running`** jobs; dep-заблокированный job остаётся `queued` (его не + клеймят) → reaper его не трогает по построению. Фиксируем в доке. + +**Наблюдаемость (TRZ §4):** блок `task_deps` в снимке `GET /queue` (read-only, по образцу +`reconcile`/`reaper`): кол-во заблокированных задач, держатель merge-lease, defer-счётчики, +обнаруженные циклы. Никогда не источник решений. + +### Конфигурация (`src/config.py`) + +| Флаг | Дефолт | Назначение | +|------|--------|-----------| +| `premerge_rebase_always` | `True` | Уровень A: безусловный pre-merge rebase под лизом. Скоуп — `merge_gate_repos`. Kill-switch (`False` → ребейз только при behind, как ORCH-043). | +| `task_deps_enabled` | `True` | Уровень B: глобальный kill-switch гейта зависимостей. `False` → `claim_next_job` 1:1 как ORCH-1. Инертно при пустой `job_deps`. | +| `task_deps_source` | `"db"` | Источник деклараций: `db`\|`plane`\|`hybrid`. Планировщик всегда читает БД-кэш. | + +Дефолты следуют конвенции репо (`*_enabled=True` + kill-switch), при этом обе фичи инертны без +данных (нет деклараций / нет применимых репо) → нулевая регрессия для enduro-trails. + +--- + +## Альтернативы (и почему отвергнуты) + +1. **Уровень A — отдельный глобальный per-repo merge-lock или FIFO merge-queue.** Дублировал бы + уже существующий merge-lease (ORCH-065), вводил второй механизм сериализации с риском + рассинхрона. Отвергнуто: BRD §4.2 требует минимально-инвазивного решения, переиспользующего + ORCH-065/043. Окно лиза уже даёт сериализацию. + +2. **Уровень A — расширять release-точки лиза (держать до отдельного `main-updated`-события).** + Не требуется: для self `done` ⇔ SHA-in-main (ORCH-073), для non-self — PR-merged webhook; + окно уже корректно. Доп. событие усложнило бы reclaim без выигрыша. + +3. **Уровень B — Plane relations как источник истины планировщика.** Сетевой запрос в горячем + цикле claim; при недоступности Plane встаёт очередь всех проектов (self-hosting risk). + Отвергнуто; Plane оставлен опциональным источником **декларации** (`task_deps_source=plane`), + но планировщик читает только БД-кэш. + +4. **Уровень B — гейт зависимостей в воркере (`_drain_once`) поверх `claim_next_job`.** Пришлось + бы клеймить job, обнаруживать незавершённую зависимость и re-queue’ить — churn, расход attempts, + гонки. SQL `NOT EXISTS` в самом `claim_next_job` чище: job просто не выбирается, слот свободен. + +5. **Уровень B — поле/JSON в `tasks` вместо таблицы.** Таблица `job_deps` нормальна (M:N), + индексируема, проще для DFS и `NOT EXISTS`. Поле в `tasks` потребовало бы парсинг-логики. + +--- + +## Последствия + +**Плюсы.** +- Минимально-инвазивно: Уровень A — один флаг + снятие short-circuit; окно сериализации не + переписывается. Переиспользует ORCH-043/065 целиком. +- Уровень B — одно `NOT EXISTS` в `claim_next_job` + аддитивная таблица + leaf-модуль + `task_deps.py`; `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки ORCH-071/058). +- Обе фичи инертны без данных → нулевая регрессия для enduro-trails (AC-A7/AC-G2). +- restart-safe (БД + файловый лиз), never-raise, kill-switch на каждую фичу. + +**Минусы / ограничения.** +- `premerge_rebase_always=True` добавляет (дешёвый, no-op на актуальной ветке) `rebase`+`push` + на каждый self-merge. Цена — лишний git-вызов; компенсируется детерминизмом анти-фантома. +- Уровень B v1 — только intra-repo зависимости; кросс-репо — follow-up (non-goal). +- Гейт B-2 в `claim_next_job` слегка усложняет горячий SQL (один `NOT EXISTS`); защищён + kill-switch и инертностью при пустой таблице. +- `task_deps.py` цикл-детектор — новая поверхность; покрывается юнит-тестами (`04-test-plan.yaml`). + +**Инварианты (не нарушать).** +1. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`check_staging_status`, + `Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений. +2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи. +3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён. +4. never-raise во всех новых функциях; restart-safe состояние; миграция БД только аддитивная. +5. ORCH-026 **дополняет** рубежи ORCH-073, не заменяет. +6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`. + +**Места реализации (для developer).** +- `src/qg/checks.py::check_branch_mergeable` — ветка `premerge_rebase_always`. +- `src/db.py::claim_next_job` — условный `NOT EXISTS`-гейт; новые helpers `add_dependency`, + `get_dependencies`, `job_deps` миграция в `init_db` (`CREATE TABLE IF NOT EXISTS`). +- `src/task_deps.py` (новый, leaf) — `is_task_ready`, `detect_cycle`, snapshot для `/queue`. +- `src/webhooks/plane.py::handle_work_item_created` — ingestion Plane relations (за `task_deps_source`). +- `src/reconciler.py` — skip dep-заблокированных + backstop цикл-детект. +- `src/notifications.py` — строка ожидания в карточке. +- `src/config.py` — `premerge_rebase_always`, `task_deps_enabled`, `task_deps_source`. +- Документация: `docs/architecture/README.md`, `CLAUDE.md` (если меняется поведение очереди), + `CHANGELOG.md`, глобальный `adr/adr-0015`. diff --git a/docs/work-items/ORCH-026/08-data-requirements.md b/docs/work-items/ORCH-026/08-data-requirements.md new file mode 100644 index 0000000..c20004c --- /dev/null +++ b/docs/work-items/ORCH-026/08-data-requirements.md @@ -0,0 +1,65 @@ +# 08 — Требования к схеме БД — ORCH-026 + +**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture +**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md` (Уровень B). + +> Уровень A (сериализация merge/деплоя) — **БЕЗ изменения схемы БД** (merge-lease файловый, +> `.merge-lease-.json`, ORCH-065). Изменения схемы касаются ТОЛЬКО Уровня B. + +--- + +## Новая таблица `job_deps` (аддитивная) + +Хранит декларативные зависимости «задача `task_id` ждёт задачу `depends_on_task_id`». + +```sql +CREATE TABLE IF NOT EXISTS job_deps ( + task_id INTEGER NOT NULL, -- tasks.id зависимой задачи (B) + depends_on_task_id INTEGER NOT NULL, -- tasks.id задачи-предшественника (A) + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (task_id, depends_on_task_id) +); +CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id); +CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id); +``` + +### Поля +| Поле | Тип | Назначение | +|------|-----|-----------| +| `task_id` | INTEGER | `tasks.id` зависимой задачи (B). Не запускается, пока зависимости не `done`. | +| `depends_on_task_id` | INTEGER | `tasks.id` предшественника (A). Терминальность — `tasks.stage = 'done'`. | +| `created_at` | TEXT | Время декларации (диагностика). | + +### Ключ и индексы +- **PK `(task_id, depends_on_task_id)`** — идемпотентность вставки (повторная декларация связи — + no-op через `INSERT OR IGNORE`), запрет дублей. +- `idx_job_deps_task` — гейт планировщика (`NOT EXISTS ... WHERE d.task_id = j.task_id`). +- `idx_job_deps_depends` — обратные рёбра для DFS цикл-детектора. + +### Семантика готовности (источник истины планировщика) +Задача `task_id` **готова к запуску** ⇔ нет ни одной строки `job_deps` для неё, чей +`depends_on_task_id` указывает на задачу с `tasks.stage != 'done'`. Терминал — только `done` +(совпадает с тем, как `get_active_tasks_for_reconcile` трактует терминальность). + +### Связь по `task_id`, а не `work_item_id` +`tasks.id` — стабильный локальный автоинкремент-ключ; `work_item_id`/`plane_id` могут +ресолвиться/коллизиться (см. `ensure_unique_work_item_id`). FK логический (без `REFERENCES`, +как у `jobs.task_id`) — не блокирует аддитивную миграцию и удаление строк tasks (которого в +конвейере нет). Зависимости — **только intra-repo** (v1); кросс-репо рёбра не создаются. + +--- + +## Миграция (AC-G4) + +- Выполняется в `src/db.py::init_db` рядом с прочими: **только** `CREATE TABLE IF NOT EXISTS` + + `CREATE INDEX IF NOT EXISTS`. **Идемпотентно**, restart-safe, безопасно на живой общей прод-БД. +- **Существующие колонки/таблицы (`jobs`, `tasks`, `agent_runs`, `events`) НЕ изменяются** → + данные enduro-trails не затронуты. +- Откат фичи — флагом `task_deps_enabled=False` (таблица остаётся, гейт не применяется); сама + таблица деструктивно не удаляется. + +## Что НЕ меняется +- Схема `jobs` (включая `available_at`, `pid`, `attempts`/`transient_attempts`) — без изменений; + defer Уровня A/B переиспользует существующий `available_at`-механизм. +- Схема `tasks` — без изменений (видимость через существующие `tracker_message_id` и Plane Blocked). +- merge-lease — файловый, вне БД. diff --git a/docs/work-items/ORCH-026/10-tech-risks.md b/docs/work-items/ORCH-026/10-tech-risks.md new file mode 100644 index 0000000..8b35485 --- /dev/null +++ b/docs/work-items/ORCH-026/10-tech-risks.md @@ -0,0 +1,17 @@ +# 10 — Технические риски — ORCH-026 + +**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture +**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md`. + +| # | Риск | Уровень | Митигация | +|---|------|---------|-----------| +| R-1 | **Гейт `NOT EXISTS` в `claim_next_job` (горячий путь всех проектов) содержит баг → встаёт очередь ВСЕХ проектов** (self-hosting групповой риск). | Высокий | Условие добавляется ТОЛЬКО при `task_deps_enabled`; инертно при пустой `job_deps` (нулевая регрессия); kill-switch `task_deps_enabled=False` мгновенно возвращает поведение ORCH-1; интеграционный тест «пустые deps ⇒ FIFO 1:1» (AC-G2). | +| R-2 | **Безусловный `premerge_rebase_always` делает лишний `push --force-with-lease` → ложный перезапуск CI / новые коммиты.** | Низкий | На актуальной ветке `rebase origin/main` — no-op (HEAD не меняется), push → «Everything up-to-date» (тот же SHA, CI не триггерится). Подтвердить тестом, что SHA не меняется на уже-актуальной ветке. | +| R-3 | **Дедлок по циклической зависимости → задача молча ждёт вечно.** | Средний | DFS-детектор `detect_cycle` при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert с перечислением цикла (AC-B3); SQL-гейт не выбирает задачу в цикле, детектор делает это видимым. | +| R-4 | **Livelock: B бесконечно defer’ится на `merge-lock busy`.** | Низкий | Существующий bounded-бюджет `merge_defer_max_attempts` → Blocked+alert (ORCH-043, без изменений). | +| R-5 | **Залипший merge-lease после смерти держателя → конвейер репо встаёт навсегда.** | Средний | Переиспользуется ORCH-065: `reclaim_stale_lease` (мёртвый `pid` / TTL `merge_lock_timeout_s`) + holder-aware release. Restart-safe (AC-A4). | +| R-6 | **Plane relations недоступны/неверно смаплены при `task_deps_source=plane`.** | Средний | Планировщик читает ТОЛЬКО БД-кэш `job_deps`; Plane-ingestion — best-effort, never-raise; дефолт `task_deps_source=db` не зависит от Plane. | +| R-7 | **reconciler «разблокирует» dep-заблокированную задачу мимо её зависимостей.** | Средний | В фильтр reconciler добавляется `is_task_ready` (паттерн ORCH-060 skip-Blocked); reaper трогает только `running` — dep-блок остаётся `queued` (AC-B5). | +| R-8 | **Миграция БД повреждает общую прод-БД (данные enduro-trails).** | Низкий | Только аддитивно: `CREATE TABLE/INDEX IF NOT EXISTS`; существующие колонки не меняются; идемпотентно (AC-G4). | +| R-9 | **Self-hosting: изменения требуют рестарта прод-контейнера вне `Confirm Deploy`.** | Высокий (если нарушено) | Все изменения — обычный код, проходят `deploy-staging` (8501) → `Confirm Deploy` (ORCH-059). `STAGE_TRANSITIONS`/`QG_CHECKS` не трогаются; никакого внеочередного рестарта (AC-A5). | +| R-10 | **Конфликт точек интеграции A (merge-gate) и B (постановка в очередь).** | Низкий | Разные точки конвейера: B гейтит claim job (вход), A гейтит merge на ребре `deploy-staging→deploy`. Независимы; покрыть интеграционным тестом совместной работы (BRD §4.4). |