Compare commits
34 Commits
1f0929838a
...
docs/ORCH-
| Author | SHA1 | Date | |
|---|---|---|---|
| 2861dea613 | |||
| 50434fc2b1 | |||
|
|
6eb9992585 | ||
| e9b23d3c04 | |||
| e3c3292ec7 | |||
| 1ada41f272 | |||
| 62b4d1f7d1 | |||
| c5007e6c90 | |||
| 10510ac48c | |||
| 8ccd17e199 | |||
| 30d9effea1 | |||
| a091a2d999 | |||
|
|
b371b6d940 | ||
| ea094f5922 | |||
| 17258fb69e | |||
| 0873803faa | |||
| 0c240198e4 | |||
| 1e1811a4bc | |||
| e89f7c7a11 | |||
| 0f82ebc1a7 | |||
| d04be97c0e | |||
| b0e517c76a | |||
|
|
662d2d6434 | ||
|
|
90a5cae8e6 | ||
|
|
1d928dab57 | ||
| 9800dc89e3 | |||
| 5b80f8facb | |||
| a74379f657 | |||
| 9019e12d98 | |||
| 518d7d18c8 | |||
| 520bcafa73 | |||
| 9f7b6edb6d | |||
| 1c3ecb973e | |||
|
|
1b45fa0008 |
57
.env.example
57
.env.example
@@ -12,6 +12,47 @@ ORCH_GITEA_WEBHOOK_SECRET=
|
||||
ORCH_CLAUDE_BIN=/usr/bin/claude
|
||||
ORCH_REPOS_DIR=/home/slin/repos
|
||||
ORCH_DB_PATH=/app/data/orchestrator.db
|
||||
|
||||
# ── Agent model / effort / fallback (ORCH-41, validation ORCH-74) ─────────────
|
||||
# Per-agent LLM model + reasoning effort, resolved by launcher.resolve_agent_*.
|
||||
# Resolution priority (per agent): project-override (projects_json agent_models/
|
||||
# agent_efforts) > ORCH_AGENT_MODEL_<AGENT> / ORCH_AGENT_EFFORT_<AGENT> >
|
||||
# ORCH_AGENT_MODEL_DEFAULT / ORCH_AGENT_EFFORT_DEFAULT > CLI default (no flag).
|
||||
# The frontmatter `model:` in .openclaw/agents/*.md is DESCRIPTIVE only and is NOT
|
||||
# read — config below is the single source of truth for the model (ORCH-74 G1).
|
||||
#
|
||||
# ORCH-74 (G2): a resolved MODEL name is validated (^claude-…$ format check) before
|
||||
# it reaches --model. A structurally invalid name (typo, gpt-4, empty) is logged and
|
||||
# the next valid level is used (in the limit: no --model flag). Forward-compatible:
|
||||
# a future claude-* version passes without editing any allowlist. EFFORT is validated
|
||||
# against low|medium|high|xhigh|max (ORCH-41); an invalid effort is dropped.
|
||||
#
|
||||
# All 6 agents resolve to claude-opus-4-8 (model-routing G3 NOT enabled). Leave the
|
||||
# per-agent overrides empty to use the default. Do NOT hardcode the model version
|
||||
# anywhere except ORCH_AGENT_MODEL_DEFAULT.
|
||||
ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8
|
||||
ORCH_AGENT_MODEL_ANALYST=
|
||||
ORCH_AGENT_MODEL_ARCHITECT=
|
||||
ORCH_AGENT_MODEL_DEVELOPER=
|
||||
ORCH_AGENT_MODEL_REVIEWER=
|
||||
ORCH_AGENT_MODEL_TESTER=
|
||||
ORCH_AGENT_MODEL_DEPLOYER=
|
||||
# Effort split (ORCH-081/ORCH-52h): thinking agents (analyst/architect/reviewer)
|
||||
# -> high; developer -> xhigh (coding/agentic role, Opus 4.8 canon); mechanical
|
||||
# agents (tester/deployer) -> medium. NB: an empty ORCH_AGENT_EFFORT_*= no longer
|
||||
# zeroes the effort — the launcher falls back to a per-role floor (= the config.py
|
||||
# class-default) so each role still runs at its canonical level (ORCH-081).
|
||||
ORCH_AGENT_EFFORT_DEFAULT=high
|
||||
ORCH_AGENT_EFFORT_ANALYST=high
|
||||
ORCH_AGENT_EFFORT_ARCHITECT=high
|
||||
ORCH_AGENT_EFFORT_DEVELOPER=xhigh
|
||||
ORCH_AGENT_EFFORT_REVIEWER=high
|
||||
ORCH_AGENT_EFFORT_TESTER=medium
|
||||
ORCH_AGENT_EFFORT_DEPLOYER=medium
|
||||
# Optional --fallback-model used when the primary is overloaded. Empty -> no flag
|
||||
# (G4 NOT enabled, ADR-001 ORCH-74: determinism — all agents stay on opus-4-8). A
|
||||
# non-empty value is validated by the SAME predicate as the model; a typo is dropped.
|
||||
ORCH_AGENT_FALLBACK_MODEL=
|
||||
# ORCH-042/ORCH-067: live-tracker mode. bump (DEFAULT since ORCH-067) -> on every
|
||||
# update the old card is deleted and a fresh one is sent silently to the BOTTOM of
|
||||
# the chat (deleteMessage + sendMessage + repoint), so the current status is always
|
||||
@@ -50,6 +91,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/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: analyst
|
||||
description: Бизнес-аналитик. Создаёт пакет документов анализа для work item.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/*)
|
||||
- Bash (git log, grep — только для чтения контекста)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: architect
|
||||
description: Архитектор системы. Принимает архитектурные решения по ТЗ, фиксирует как ADR.
|
||||
model: claude-opus-4-7
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/)
|
||||
- Bash (read-only: grep, git log)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: deployer
|
||||
description: DevOps-агент. Запускает staging-проверку и/или прод-деплой. Пишет 15-staging-log.md и 14-deploy-log.md.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/*/14-deploy-log.md, docs/work-items/*/15-staging-log.md)
|
||||
- Bash (docker, git, curl, ssh)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: developer
|
||||
description: Senior разработчик. Реализует ТЗ по ADR, пишет тесты, открывает PR.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write — src/, tests/, docs/work-items/*/[07-10]*, CHANGELOG.md)
|
||||
- Git (commit, push; merge запрещён)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: Senior code reviewer. Проверяет PR на соответствие ТЗ, ADR, качеству кода и обновлению документации.
|
||||
model: claude-opus-4-7
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/12-review.md)
|
||||
- Git (read-only: log, diff, blame)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: tester
|
||||
description: QA-инженер. Прогоняет тесты, оформляет отчёт.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/13-test-report.md)
|
||||
- Bash (pytest, curl)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
## Стек
|
||||
- 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)
|
||||
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break).
|
||||
- Очередь задач: собственная (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
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
|
||||
- **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.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. Модель/эффорт каждого агента резолвятся из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41), а не из frontmatter промпта. **ORCH-74:** имя модели валидируется форматом `^claude-…$` (`is_valid_model`) перед `--model`; невалидное → лог + откат на следующий уровень/CLI-дефолт (never-break, как `VALID_EFFORTS` для эффорта). Тот же предикат гардит inline-чтение `--fallback-model`.
|
||||
- **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.
|
||||
@@ -41,6 +41,20 @@ created → analysis → architecture → development → review → testing →
|
||||
|
||||
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
|
||||
|
||||
### Модель и эффорт по ролям (ORCH-41, валидация ORCH-74)
|
||||
Модель и `--effort` каждого агента берутся из config (`src/config.py`), резолвятся `launcher.resolve_agent_model` / `resolve_agent_effort` по приоритету **project-override (`projects_json` `agent_models`/`agent_efforts`) > `ORCH_AGENT_MODEL_<AGENT>`/`ORCH_AGENT_EFFORT_<AGENT>` > `*_default` > CLI-дефолт (без флага)**. **Эффорт (ORCH-081):** ниже `*_default` добавлен непустой **per-role floor** — class-default поля `agent_effort_<role>` из `config.py` (его пустой env перебить не может). Floor — строго последний уровень (ниже default) и срабатывает ТОЛЬКО когда все уровни пусты, поэтому пустые прод-`ORCH_AGENT_EFFORT_*=` (которые pydantic трактует как явное `''` и обнуляют дефолт) больше не приводят к запуску без `--effort`: каждая роль получает свой канонический пол (developer=`xhigh`, tester/deployer=`medium`, прочие=`high`). Непустой явный конфиг по-прежнему побеждает floor; опечатка вне `VALID_EFFORTS` дропается валидацией ДО floor (never-break, не маскируется). См. `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. frontmatter `model:` в `.openclaw/agents/*.md` **удалён** (ORCH-74 G1) — он был мёртвой/лживой декларацией (launcher его не читает); config — единственный источник правды о модели. Model-routing (G3) НЕ включён — все 6 агентов на `claude-opus-4-8`.
|
||||
|
||||
| Агент | Модель | Эффорт |
|
||||
|-------|--------|--------|
|
||||
| analyst | claude-opus-4-8 | high |
|
||||
| architect | claude-opus-4-8 | high |
|
||||
| developer | claude-opus-4-8 | xhigh |
|
||||
| reviewer | claude-opus-4-8 | high |
|
||||
| tester | claude-opus-4-8 | medium |
|
||||
| deployer | claude-opus-4-8 | medium |
|
||||
|
||||
**Валидация (ORCH-74 G2, never-break):** резолвенное имя модели проходит формат-чек `is_valid_model` (`^claude-[a-z0-9.-]+$`) перед попаданием в `--model`. Невалидное (опечатка, `gpt-4`, пустое) → `logger.warning` + откат на следующий валидный уровень (в пределе — без `--model`, CLI-дефолт); мусор **никогда** не уезжает в CLI и запуск не падает. Форма — формат-чек, а не статичный allowlist: forward-compatible (будущие `claude-*` проходят без правки кода). Тот же предикат гардит inline-чтение `--fallback-model` (`agent_fallback_model` читается мимо резолва — TRZ §4). Эффорт валидируется множеством `VALID_EFFORTS` (`low|medium|high|xhigh|max`). Fallback (G4) НЕ включён (`agent_fallback_model=""`). Детали — `docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md`.
|
||||
|
||||
### Условный staging-гейт (ORCH-35)
|
||||
`check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)` → `orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md).
|
||||
|
||||
@@ -59,11 +73,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 +479,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>`.
|
||||
|
||||
@@ -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»).
|
||||
|
||||
## Формат
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# adr-0015: Зависимости задач + сериализация merge внутри репо
|
||||
|
||||
**Статус:** accepted · **Дата:** 2026-06-08 · **Источник:** ORCH-026
|
||||
**Связи:** дополняет adr-0006 (merge-gate), adr-0011 (merge-lease + reclaim), adr-0013/0014
|
||||
(merge-verify, SHA-in-main), adr-0002 (очередь). Детально —
|
||||
`docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
|
||||
|
||||
## Контекст
|
||||
|
||||
Эрозия `main` 08.06 родилась из некоординированного параллелизма задач одного репо (ветки от
|
||||
устаревшего `main`, фантом-merge затирает соседа). adr-0014 закрыл последствия; ORCH-026 — корень
|
||||
на уровне планировщика. Плюс исходный скоуп ORCH-026: декларативные зависимости задач (B ждёт A).
|
||||
|
||||
## Решение
|
||||
|
||||
**Уровень A — сериализация merge/деплоя (per-repo).** Окно сериализации уже обеспечивается
|
||||
merge-lease (adr-0011): захват в `check_branch_mergeable`, удержание до release (PR-merged webhook /
|
||||
`deploy→done`=SHA-in-main для self / откат / проактивный reclaim). Это и есть окно
|
||||
«merge → main-updated» — **механизм не переписывается**. Добавляется единственное новое поведение:
|
||||
**безусловный proactive pre-merge rebase** (флаг `premerge_rebase_always`, дефолт `True`, скоуп
|
||||
`merge_gate_repos`): под лизом всегда вызывается `auto_rebase_onto_main` (no-op + «Everything
|
||||
up-to-date» на актуальной ветке → CI не триггерится; реальный догон на отстающей). Инвариант:
|
||||
никаких push в `main`, force только `--force-with-lease` на ветку.
|
||||
|
||||
**Уровень B — декларативные зависимости.** Аддитивная таблица `job_deps(task_id,
|
||||
depends_on_task_id)` — **источник истины планировщика** (offline-устойчивость: сетевой Plane в
|
||||
горячем claim встанет очередью всех проектов). Источник декларации настраивается
|
||||
`task_deps_source = db|plane|hybrid` (дефолт `db`); планировщик всегда читает БД-кэш. Гейт —
|
||||
условие `NOT EXISTS` в `claim_next_job` (задача не выбирается, пока есть незавершённая зависимость;
|
||||
слот `max_concurrency` не занимается). Циклы — DFS-детектор (`src/task_deps.py`) + `set_issue_blocked`
|
||||
+ alert. Видимость — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (Plane Blocked — на дедлоке).
|
||||
Зависимости — только intra-repo (v1).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
Отдельный merge-lock/merge-queue (дублирует adr-0011); расширение release-точек лиза (не нужно —
|
||||
окно уже корректно); Plane как источник истины планировщика (self-hosting risk); гейт зависимостей
|
||||
в воркере с claim+requeue (churn vs. чистый `NOT EXISTS`); поле в `tasks` вместо таблицы (M:N хуже).
|
||||
|
||||
## Последствия
|
||||
|
||||
Минимально-инвазивно: `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки), переиспользует
|
||||
merge-gate/merge-lease целиком. Обе фичи инертны без данных → нулевая регрессия для enduro-trails.
|
||||
restart-safe, never-raise, kill-switch на каждую (`premerge_rebase_always`, `task_deps_enabled`).
|
||||
Миграция — только аддитивная (`CREATE TABLE/INDEX IF NOT EXISTS`). Ограничение: B v1 — intra-repo.
|
||||
Self-hosting safety: изменения идут через `deploy-staging` → `Confirm Deploy`, без внеочередного
|
||||
рестарта прода.
|
||||
7
docs/work-items/ORCH-026/00-business-request.md
Normal file
7
docs/work-items/ORCH-026/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Управление зависимостями задач (B ждёт A) в очереди
|
||||
|
||||
Work Item ID: ORCH-026
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
135
docs/work-items/ORCH-026/01-brd.md
Normal file
135
docs/work-items/ORCH-026/01-brd.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 01-BRD — Управление зависимостями задач (B ждёт A) в очереди
|
||||
|
||||
**Work Item:** ORCH-026
|
||||
**Repo:** orchestrator (self-hosting)
|
||||
**Branch:** feature/ORCH-026-b-a
|
||||
**Стадия:** analysis
|
||||
**Источник:** предложение Стрим, одобрено Славой (2026-06-04); дополнение Слава+Стрим 2026-06-08 (инцидент эрозии `main`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
### 1.1 Первопричина (мотивация СЕЙЧАС — инцидент 08.06)
|
||||
Эрозия `main` 08.06 (потеря кода ORCH-067/069, фантом-merge) родилась НЕ из логических
|
||||
зависимостей, а из **некоординированного параллелизма**: несколько self-hosting задач
|
||||
(ORCH-067/069/071) одновременно срезали ветки от `main` и правили общие файлы
|
||||
(`CHANGELOG.md`, `notifications.py`, `config.py`). Последствия:
|
||||
|
||||
- CHANGELOG-конфликты на `auto_rebase` → откаты `deploy-staging → development` (дорого:
|
||||
ORCH-069 = 3 попытки = $3.98);
|
||||
- тихое затирание кода соседа при merge ветки, срезанной от устаревшего `main` (фантом).
|
||||
|
||||
**ORCH-073** закрыл ПОСЛЕДСТВИЯ (3 рубежа: CHANGELOG `merge=union` + SHA-in-main verify +
|
||||
регресс-гард маркеров). ORCH-026 должен закрыть **ПЕРВОПРИЧИНУ**: задачи одного репо не
|
||||
должны мешать друг другу в `main`.
|
||||
|
||||
### 1.2 Исходный скоуп (плоская очередь ORCH-1)
|
||||
Очередь (`src/queue_worker.py`, ORCH-1) — плоская: `jobs` упорядочены по `id` (FIFO),
|
||||
гейтятся только `available_at` и `max_concurrency`. Нельзя выразить «задача B не стартует,
|
||||
пока не готова A». Декомпозиция эпиков (ORCH-025) порождает заведомо зависимые подзадачи.
|
||||
|
||||
### 1.3 Что уже есть (опора, НЕ переписывать)
|
||||
- **ORCH-1** — персистентная очередь (`jobs`), atomic claim, `available_at`-defer, restart-safe.
|
||||
- **ORCH-065** — `merge-lease` (`src/merge_gate.py`): per-repo файловый лиз
|
||||
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный
|
||||
реклейм мёртвого/устаревшего держателя. **Сейчас лиз держится только на ребре
|
||||
`deploy-staging → deploy`** (от merge-gate до фактического merge).
|
||||
- **ORCH-043** — merge-gate: `branch_is_behind_main`, `auto_rebase_onto_main` (rebase
|
||||
**только когда ветка отстаёт или при конфликте**), `retest_branch`.
|
||||
- **ORCH-073** — merge-verify: `verify_merged_to_main` (SHA-in-main), `check_main_regression`.
|
||||
- **Plane-статусы** `Blocked` / `Needs Input` + `set_issue_blocked` (`src/plane_sync.py`).
|
||||
- **Telegram live-tracker** (`src/notifications.py`) — одна карточка на задачу, уже умеет
|
||||
показывать статус `Blocked`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Цель (бизнес-результат)
|
||||
|
||||
Задачи одного репозитория перестают повреждать `main` друг друга, а очередь умеет
|
||||
выражать логические зависимости между задачами — БЕЗ потери параллелизма между разными
|
||||
репозиториями и без риска для self-hosting прода.
|
||||
|
||||
---
|
||||
|
||||
## 3. Два уровня требований (объединить в одной задаче; приоритет — Уровень A)
|
||||
|
||||
### Уровень A — Сериализация merge/деплоя внутри ОДНОГО репо (КРИТИЧНО, корень эрозии)
|
||||
Закрывает первопричину инцидента 08.06.
|
||||
|
||||
- **A-1.** В рамках ОДНОГО репо merge-в-`main` + деплой должны быть **сериализованы**: пока
|
||||
задача A не слита в `main` (и для self-hosting — не задеплоена), задача B того же репо НЕ
|
||||
доходит до своего merge/деплоя от устаревшего `main`.
|
||||
- **A-2.** B перед своим merge-gate **обязана ребейзнуться на СВЕЖИЙ `main`** (где уже есть
|
||||
A) — **proactive pre-merge rebase**, а не только при текстовом конфликте (как сейчас в
|
||||
ORCH-043). Цель: B всегда несёт актуальный код предшественников → структурный анти-фантом
|
||||
на уровне планировщика (дополняет рубежи ORCH-073, не заменяет).
|
||||
- **A-3.** Сериализация — **только внутри одного репо**. Задачи РАЗНЫХ репо (orchestrator vs
|
||||
enduro-trails) параллелятся свободно (общая БД/очередь — пропускная способность не падает).
|
||||
- **A-4.** Механизм — минимально-инвазивный и **restart-safe** (как ORCH-1/065): переживает
|
||||
рестарт прод-контейнера, не оставляет навсегда захваченных ресурсов (опора на проактивный
|
||||
реклейм ORCH-065).
|
||||
- **A-5.** **Совместимость с self-hosting safety:** не ронять/не рестартить прод-контейнер
|
||||
вне штатного deploy; гейт `Confirm Deploy` (ORCH-059) сохранён; никаких push/force-push в
|
||||
`main`.
|
||||
- **A-6.** Защита от взаимоблокировки: B при занятой сериализации **defer** (повторная
|
||||
постановка с задержкой через `available_at`), а НЕ откат на `development` и НЕ вечное
|
||||
ожидание; bounded defer-бюджет (анти-livelock, как `merge_defer_max_attempts`).
|
||||
|
||||
### Уровень B — Декларативные зависимости (исходный скоуп ORCH-26)
|
||||
- **B-1.** Задача может объявить связь `blocked-by` / `blocks` (depends-on).
|
||||
- **B-2.** Планировщик очереди (ORCH-1) **не запускает** заблокированную задачу, пока все её
|
||||
depends-on не достигли терминального состояния (`done`).
|
||||
- **B-3.** **Защита от дедлоков:** циклические зависимости детектируются; задача в цикле не
|
||||
«пропадает молча» — выставляется `Blocked` + alert (Telegram/Plane).
|
||||
- **B-4.** **Видимость:** заблокированная задача видна — Plane-статус `Blocked` и/или
|
||||
ожидание в Telegram-карточке (что и кого ждёт).
|
||||
|
||||
---
|
||||
|
||||
## 4. Открытые вопросы для архитектора (НЕ решаются на этапе анализа)
|
||||
|
||||
> Аналитик фиксирует требования; выбор механизма — за архитектором (ADR в `06-adr/`).
|
||||
|
||||
1. **Где хранить связи (Уровень B):** Plane relations (родное, видимо в UI, но требует
|
||||
сетевого запроса и зависит от Plane) vs таблица в БД (`job_deps`/поля `tasks`, надёжно и
|
||||
offline, но дубль источника) vs **гибрид** (Plane — источник декларации, БД — кэш для
|
||||
планировщика). Рекомендация анализа: гибрид с offline-fallback (см. §6).
|
||||
2. **Механизм сериализации (Уровень A):** глобальный per-repo merge-lock vs FIFO merge-queue
|
||||
vs **обязательный pre-merge rebase + расширение окна merge-lease** (от «момента merge» до
|
||||
«main-updated»). Выбрать минимально-инвазивный, restart-safe, переиспользующий ORCH-065/043.
|
||||
3. **Граница окна сериализации для self-hosting:** для не-self репо «merged в main» = конец
|
||||
окна; для self (orchestrator) деплой асинхронный (Phase B/C, ORCH-036/071) — нужно решить,
|
||||
до какого события держать лиз (до `merged_to_main: true` / до `done`).
|
||||
4. **Совместимость B и A:** depends-on (B) на уровне постановки в очередь vs merge-сериализация
|
||||
(A) на уровне merge-gate — разные точки конвейера; убедиться, что не конфликтуют.
|
||||
|
||||
---
|
||||
|
||||
## 5. Вне скоупа (Non-goals)
|
||||
- Изменение машины стадий `STAGE_TRANSITIONS` (сериализация/зависимости — врезки/гейты, не
|
||||
новые стадии — паттерн ORCH-043/058/071).
|
||||
- Приоритизация/перепланирование задач по весам (только зависимости и сериализация).
|
||||
- Кросс-репо зависимости (A-3 явно запрещает кросс-репо сериализацию; кросс-репо логические
|
||||
зависимости — возможный follow-up, не v1).
|
||||
- Отмена/замена рубежей ORCH-073 — ORCH-026 их **дополняет** на уровне планировщика.
|
||||
|
||||
---
|
||||
|
||||
## 6. Заинтересованные стороны
|
||||
- **Owner (Слава)** — одобряет BRD; держатель self-hosting прод-риска.
|
||||
- **Стрим** — автор предложения.
|
||||
- **Конвейер агентов** — потребитель: developer/deployer работают с веткой, которую затрагивает
|
||||
сериализация; reviewer проверяет обновление доки.
|
||||
|
||||
---
|
||||
|
||||
## 7. Критерии успеха (бизнес-уровень)
|
||||
- Две зелёные задачи одного репо больше не способны затереть код друг друга в `main` на уровне
|
||||
планировщика (без участия рубежей-последствий ORCH-073).
|
||||
- Задача может объявить зависимость; заблокированная задача не стартует раньше времени и видна
|
||||
наблюдателю.
|
||||
- Пропускная способность разных репо не деградирует.
|
||||
- Прод-контейнер orchestrator не падает и не рестартится вне штатного `Confirm Deploy`.
|
||||
|
||||
Точные PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
134
docs/work-items/ORCH-026/02-trz.md
Normal file
134
docs/work-items/ORCH-026/02-trz.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 02-ТЗ — Управление зависимостями задач (B ждёт A) в очереди
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
|
||||
|
||||
> ТЗ фиксирует ТРЕБОВАНИЯ к изменениям (модули, контракты, артефакты). Конкретный механизм
|
||||
> сериализации и место хранения связей — решение архитектора (ADR в `06-adr/`); ниже отмечены
|
||||
> как «КАНДИДАТ / решает архитектор». Аналитик не предлагает архитектуру.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче | Уровень |
|
||||
|--------|---------------|---------|
|
||||
| `src/queue_worker.py` | Планировщик: `_drain_once` / `claim_next_job` — точка учёта зависимостей и сериализации при выборе job. | A + B |
|
||||
| `src/db.py` | Очередь `jobs` / `tasks`; `claim_next_job`, `enqueue_job`, `count_running_jobs`. Кандидат на хранение связей и блокировки claim. | A + B |
|
||||
| `src/merge_gate.py` | merge-lease (ORCH-065), `branch_is_behind_main` / `auto_rebase_onto_main` (ORCH-043) — опора для proactive pre-merge rebase и расширения окна сериализации. | A |
|
||||
| `src/qg/checks.py` | `check_branch_mergeable` (под-гейт ребра `deploy-staging → deploy`) — точка форсированного pre-merge rebase. | A |
|
||||
| `src/stage_engine.py` | `advance_stage` — врезки гейтов; точка интеграции сериализации/верификации. | A |
|
||||
| `src/webhooks/plane.py` | `handle_work_item_created` / `start_pipeline` — приём задачи; точка чтения relations (если источник — Plane). | B |
|
||||
| `src/plane_sync.py` | `set_issue_blocked`, `get_project_states` (`blocked`/`needs_input`), relations API. | B |
|
||||
| `src/notifications.py` | live-карточка: индикация `Blocked` / «ждёт ORCH-NNN». | B |
|
||||
| `src/config.py` | Новые kill-switch + scope-настройки (паттерн `*_enabled` / `*_repos`). | A + B |
|
||||
| `src/reconciler.py` / `src/job_reaper.py` | Не ломать: skip заблокированных задач (как уже делается для Blocked/Needs-Input, ORCH-060/068); реклейм ресурсов сериализации. | A + B |
|
||||
|
||||
---
|
||||
|
||||
## 2. Требования к изменениям — Уровень A (сериализация merge/деплоя)
|
||||
|
||||
### 2.1 Proactive pre-merge rebase (A-2)
|
||||
- На ребре `deploy-staging → deploy`, ДО фактического merge (в составе `check_branch_mergeable`
|
||||
или соседнего под-гейта), ветка задачи **всегда** догоняется на свежий `origin/main` —
|
||||
**не только при `branch_is_behind_main`/конфликте**.
|
||||
- Переиспользовать `merge_gate.auto_rebase_onto_main` (rebase + `push --force-with-lease`
|
||||
ТОЛЬКО ветки задачи). Текстовый конфликт → существующий контракт: `rebase --abort` → откат на
|
||||
`development` (как ORCH-043).
|
||||
- **Инвариант:** никаких push/force-push в `main`.
|
||||
|
||||
### 2.2 Расширение окна merge-lease (A-1, A-3, A-4)
|
||||
- **КАНДИДАТ (решает архитектор):** держать per-repo merge-lease (ORCH-065) не только «на
|
||||
момент merge», а на окно **«merge → main-updated»** (для self — до подтверждения
|
||||
`merged_to_main: true` / `done`), чтобы B не дошла до своего merge, пока A не в `main`.
|
||||
- Acquire — **неблокирующий** (как сейчас): занято → **defer** задачи B через
|
||||
`enqueue_job(available_at_delay_s=...)`, bounded бюджет (анти-livelock; ср.
|
||||
`merge_defer_max_attempts`). Откат на `development` НЕ применять для defer.
|
||||
- Release — holder-aware (как `release_merge_lease`), на merged-вебхуке / `deploy→done` /
|
||||
откате / по проактивному реклейму (ORCH-065 `reclaim_stale_lease`).
|
||||
- Сериализация **строго per-repo** (`.merge-lease-<repo>.json`) — кросс-репо параллелизм не
|
||||
затрагивается (A-3).
|
||||
|
||||
### 2.3 Условность и безопасность (A-5)
|
||||
- Реально только для применимых репо: kill-switch + CSV-scope (паттерн `merge_gate_repos` /
|
||||
`merge_verify_repos`; пусто → только self-hosting `orchestrator`).
|
||||
- `STAGE_TRANSITIONS`, `Confirm Deploy` (ORCH-059), exit-коды deploy-хука, БАГ-8,
|
||||
terminal-sync — **без изменений**.
|
||||
- Контракт **never-raise** для всех новых функций (как соседи в `merge_gate.py`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Требования к изменениям — Уровень B (декларативные зависимости)
|
||||
|
||||
### 3.1 Декларация связи (B-1)
|
||||
- **КАНДИДАТ хранения (решает архитектор, см. BRD §4.1):**
|
||||
- вариант Plane relations: читать `blocked-by` через Plane API в `handle_work_item_created`;
|
||||
- вариант БД: новая таблица `job_deps(task_id, depends_on_task_id)` или поле в `tasks`
|
||||
(idempotent `_ensure_column` миграция, как ORCH-065 `jobs.pid`);
|
||||
- гибрид: Plane — декларация, БД — кэш для планировщика (offline-устойчивость).
|
||||
- Миграция БД (если выбран вариант с таблицей/колонкой) — **только аддитивная**
|
||||
(`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), безопасная на живой прод-БД с общими
|
||||
данными enduro-trails.
|
||||
|
||||
### 3.2 Гейт планировщика (B-2)
|
||||
- При выборе job (`claim_next_job` / `_drain_once`) задача с незавершёнными depends-on
|
||||
**не клеймится** (аналог `available_at`-gate): пропускается до тех пор, пока все depends-on
|
||||
не `done`. Не должна занимать слот `max_concurrency`.
|
||||
- Реализация — **leaf-функция** с чистой логикой «готова ли задача к запуску» (тестируемо
|
||||
юнитами, never-raise), по образцу `staging_verdict.py` / `post_deploy.py`.
|
||||
|
||||
### 3.3 Защита от дедлоков (B-3)
|
||||
- Детектор циклов в графе depends-on (DFS/обнаружение цикла) — чистая функция, юнит-тестируемая.
|
||||
- Цикл → задача(и) НЕ запускается молча: `set_issue_blocked` + alert (Telegram/Plane) с
|
||||
указанием цикла. Не блокировать поток других задач.
|
||||
|
||||
### 3.4 Видимость (B-4)
|
||||
- Заблокированная задача: Plane-статус `Blocked` (`set_issue_blocked`) и/или строка ожидания в
|
||||
Telegram-карточке («⏳ ждёт ORCH-NNN»). Использовать существующий механизм карточки
|
||||
(`notifications.update_task_tracker`), контракт never-raise / silent.
|
||||
- `reconciler` F-1 уже пропускает Blocked/Needs-Input (ORCH-060/068) — убедиться, что новые
|
||||
заблокированные-по-зависимости задачи тоже пропускаются (не «разблокируются» ошибочно).
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения API (endpoints)
|
||||
- **Новые HTTP endpoints не требуются.**
|
||||
- **Наблюдаемость:** расширить снимок `GET /queue` блоком о зависимостях/сериализации
|
||||
(по образцу блоков `reconcile` / `reaper` / `post_deploy` / `merge_verify`): кол-во
|
||||
заблокированных задач, держатель merge-lease, defer-счётчики, обнаруженные циклы. Read-only,
|
||||
никогда не источник истины для решений.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
- **КАНДИДАТ (если выбран БД/гибрид для Уровня B):** аддитивная таблица `job_deps` или колонка
|
||||
в `tasks` (см. §3.1). Только `CREATE TABLE IF NOT EXISTS` / `_ensure_column`. Без изменения
|
||||
существующих колонок `jobs`/`tasks`. Restart-safe, безопасно на общей прод-БД.
|
||||
- Уровень A (сериализация) — **без изменения схемы БД** (merge-lease файловый, как ORCH-065).
|
||||
|
||||
## 6. Требования к новым QG checks
|
||||
- **Новый зарегистрированный QG-чек НЕ вводится** (паттерн ORCH-071/058: под-гейт — врезка в
|
||||
`advance_stage` или расширение `check_branch_mergeable`, а не новая запись в `QG_CHECKS`).
|
||||
- Реестр `QG_CHECKS` — без изменений.
|
||||
|
||||
## 7. Конфигурация (`src/config.py`)
|
||||
Новые настройки по паттерну `*_enabled` (kill-switch) + `*_repos` (CSV scope, пусто →
|
||||
self-hosting). КАНДИДАТ-имена (финализирует архитектор):
|
||||
- Уровень A: `merge_serialize_enabled` / `merge_serialize_repos` (или расширение
|
||||
`merge_gate_*`); опционально `premerge_rebase_always` (вкл proactive rebase).
|
||||
- Уровень B: `task_deps_enabled` / `task_deps_source` (`plane|db|hybrid`).
|
||||
Дефолты — обратная совместимость (для не-self репо — прежнее поведение).
|
||||
|
||||
## 8. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR)
|
||||
- `06-adr/ADR-001-*.md` — решение по сериализации (A) и хранению зависимостей (B).
|
||||
- Обновить `docs/architecture/README.md` (раздел про очередь/merge-gate/сериализацию).
|
||||
- Обновить `CLAUDE.md` (паспорт: конвейер/инварианты, если меняется поведение очереди).
|
||||
- Обновить `CHANGELOG.md` (`## [Unreleased]`).
|
||||
- Если вводится таблица БД — отразить в `08-data-requirements.md` (создаёт архитектор).
|
||||
- `07-infra-requirements.md` — если требуется новый Plane-статус/настройка relations.
|
||||
|
||||
## 9. Инварианты (НЕ нарушать)
|
||||
1. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
|
||||
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
|
||||
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
|
||||
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
|
||||
4. never-raise во всех новых функциях; restart-safe состояние.
|
||||
5. ORCH-026 дополняет рубежи ORCH-073, не заменяет.
|
||||
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.
|
||||
107
docs/work-items/ORCH-026/03-acceptance-criteria.md
Normal file
107
docs/work-items/ORCH-026/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 03-Критерии приёмки — ORCH-026
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
|
||||
|
||||
Каждый критерий — проверяемое условие PASS/FAIL. Маппинг на тесты — `04-test-plan.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Уровень A — Сериализация merge/деплоя внутри одного репо
|
||||
|
||||
### AC-A1 — Сериализация merge внутри репо
|
||||
- **PASS:** пока задача A применимого репо удерживает окно merge (merge-lease не освобождён /
|
||||
`main` ещё не обновлён), задача B того же репо НЕ доходит до фактического merge — она
|
||||
**defer**-ится (повторная постановка через `available_at`), а не мержится от устаревшего `main`.
|
||||
- **FAIL:** B мержится/деплоится, пока A не в `main`; или B откатывается на `development` вместо
|
||||
defer.
|
||||
|
||||
### AC-A2 — Proactive pre-merge rebase
|
||||
- **PASS:** перед merge ветка задачи **всегда** догоняется на свежий `origin/main` (вызывается
|
||||
rebase), даже когда текстового конфликта нет и ветка формально не «behind» по старой проверке;
|
||||
после rebase ветка содержит код предшественника (A).
|
||||
- **FAIL:** rebase запускается только при конфликте/`branch_is_behind_main`, и B мержится без
|
||||
кода A.
|
||||
|
||||
### AC-A3 — Кросс-репо параллелизм сохранён
|
||||
- **PASS:** задача в `orchestrator` и задача в `enduro-trails` доходят до merge/деплоя
|
||||
параллельно — сериализация одного репо не блокирует другой (lease/гейт строго per-repo).
|
||||
- **FAIL:** задача одного репо ждёт освобождения ресурса, удерживаемого задачей ДРУГОГО репо.
|
||||
|
||||
### AC-A4 — Restart-safe
|
||||
- **PASS:** после рестарта прод-контейнера состояние сериализации восстанавливается корректно;
|
||||
мёртвый держатель merge-lease проактивно реклеймится (ORCH-065), конвейер не встаёт навсегда.
|
||||
- **FAIL:** рестарт оставляет навсегда захваченный lease → конвейер всех проектов встаёт.
|
||||
|
||||
### AC-A5 — Self-hosting safety
|
||||
- **PASS:** прод-контейнер orchestrator НЕ рестартится/не падает вне штатного `Confirm Deploy`
|
||||
(ORCH-059); нет push/force-push в `main`; `STAGE_TRANSITIONS` и реестр `QG_CHECKS` не изменены.
|
||||
- **FAIL:** любой незапрошенный рестарт прода, прямой push в `main`, или изменение машины стадий.
|
||||
|
||||
### AC-A6 — Anti-deadlock / anti-livelock при defer
|
||||
- **PASS:** при занятой сериализации B defer-ится с задержкой и bounded бюджетом; исчерпание
|
||||
бюджета → эскалация (alert/Blocked), не бесконечный цикл и не откат.
|
||||
- **FAIL:** B уходит в вечный defer-цикл, либо немедленно откатывается на `development`.
|
||||
|
||||
### AC-A7 — Условность (не-self репо без регресса)
|
||||
- **PASS:** при выключенном kill-switch и для репо вне scope поведение конвейера 1:1 как до
|
||||
ORCH-026 (нулевая регрессия для enduro-trails).
|
||||
- **FAIL:** не-self репо меняет поведение merge/деплоя.
|
||||
|
||||
---
|
||||
|
||||
## Уровень B — Декларативные зависимости
|
||||
|
||||
### AC-B1 — Декларация зависимости
|
||||
- **PASS:** задача может объявить `blocked-by`/`depends-on` (через выбранный источник —
|
||||
Plane relations / БД / гибрid), и связь корректно считывается планировщиком.
|
||||
- **FAIL:** связь не считывается / теряется.
|
||||
|
||||
### AC-B2 — Гейт планировщика (B не стартует до A)
|
||||
- **PASS:** задача с незавершённым depends-on **не клеймится** воркером (не запускается агент,
|
||||
слот `max_concurrency` не занимается), пока все depends-on не достигли `done`; как только A
|
||||
становится `done` — B становится claimable.
|
||||
- **FAIL:** B запускается раньше завершения A; или занимает слот, простаивая.
|
||||
|
||||
### AC-B3 — Детект дедлоков (циклы)
|
||||
- **PASS:** циклическая зависимость (A→B→A и длиннее) детектируется детерминированно; задача(и)
|
||||
в цикле → `Blocked` + alert (Telegram/Plane) с указанием цикла; поток остальных задач не
|
||||
блокируется.
|
||||
- **FAIL:** цикл приводит к молчаливому вечному ожиданию или к падению воркера.
|
||||
|
||||
### AC-B4 — Видимость заблокированной задачи
|
||||
- **PASS:** заблокированная задача видна — Plane-статус `Blocked` и/или строка ожидания в
|
||||
Telegram-карточке (что/кого ждёт); инвариант «одна карточка на задачу» сохранён.
|
||||
- **FAIL:** заблокированная задача невидима наблюдателю.
|
||||
|
||||
### AC-B5 — Совместимость с reconciler/reaper
|
||||
- **PASS:** `reconciler` F-1 НЕ «разблокирует» задачу, заблокированную по зависимости (как уже
|
||||
делает для Blocked/Needs-Input, ORCH-060/068); reaper не реапит корректно ожидающую задачу.
|
||||
- **FAIL:** reconciler продвигает заблокированную задачу мимо её depends-on.
|
||||
|
||||
---
|
||||
|
||||
## Общие (оба уровня)
|
||||
|
||||
### AC-G1 — never-raise
|
||||
- **PASS:** любая ошибка (git/сеть/БД/Plane) в новой логике не пробрасывается в `advance_stage`/
|
||||
воркер; деградирует консервативно (defer/skip/fail-closed), конвейер не падает.
|
||||
- **FAIL:** необработанное исключение роняет воркер/монитор-поток.
|
||||
|
||||
### AC-G2 — Kill-switch
|
||||
- **PASS:** глобальный kill-switch выключает фичу целиком → поведение 1:1 как до ORCH-026.
|
||||
- **FAIL:** при выключенном флаге поведение изменено.
|
||||
|
||||
### AC-G3 — Документация обновлена (golden source)
|
||||
- **PASS:** в ТОМ ЖЕ PR обновлены `docs/architecture/README.md`, `CLAUDE.md` (если изменилось
|
||||
поведение очереди), `CHANGELOG.md`, заведён ADR в `06-adr/`. Reviewer проверяет.
|
||||
- **FAIL:** код изменён, документация — нет (→ REQUEST_CHANGES).
|
||||
|
||||
### AC-G4 — Миграция БД безопасна (если применимо)
|
||||
- **PASS:** миграция только аддитивная (`CREATE TABLE IF NOT EXISTS`/`_ensure_column`),
|
||||
идемпотентна, безопасна на живой общей прод-БД; существующие данные enduro-trails не затронуты.
|
||||
- **FAIL:** деструктивная миграция / изменение существующих колонок.
|
||||
|
||||
### AC-G5 — Тесты зелёные
|
||||
- **PASS:** новые unit+integration тесты (`04-test-plan.yaml`) проходят; существующий
|
||||
`pytest tests/ -q` остаётся зелёным (нет регресса merge-gate/merge-verify/reconciler/reaper).
|
||||
- **FAIL:** красный pytest или регресс существующих тестов.
|
||||
169
docs/work-items/ORCH-026/04-test-plan.yaml
Normal file
169
docs/work-items/ORCH-026/04-test-plan.yaml
Normal file
@@ -0,0 +1,169 @@
|
||||
work_item: ORCH-026
|
||||
description: >
|
||||
План тестов для управления зависимостями задач (Уровень B) и сериализации
|
||||
merge/деплоя внутри одного репо (Уровень A). Стек: pytest. Имена модулей/функций —
|
||||
кандидаты; финализирует архитектор/разработчик. Все новые функции — never-raise.
|
||||
|
||||
tests:
|
||||
# ---------------- Уровень A: сериализация merge/деплоя ----------------
|
||||
- id: TC-A01
|
||||
type: unit
|
||||
description: >
|
||||
Proactive pre-merge rebase: ветка догоняется на свежий origin/main ДАЖЕ когда
|
||||
branch_is_behind_main вернул бы False (нет конфликта). Проверить, что rebase
|
||||
вызывается всегда перед merge (AC-A2).
|
||||
module: tests/test_orch026_premerge_rebase.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A02
|
||||
type: unit
|
||||
description: >
|
||||
Расширенное окно merge-lease: пока A держит lease (окно merge→main-updated),
|
||||
acquire для B того же репо возвращает busy → defer (не откат). holder-aware
|
||||
release не удаляет чужой lease (AC-A1, AC-A6).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A03
|
||||
type: unit
|
||||
description: >
|
||||
Сериализация строго per-repo: lease/гейт orchestrator не влияет на задачу
|
||||
enduro-trails — обе claimable параллельно (AC-A3).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A04
|
||||
type: unit
|
||||
description: >
|
||||
Restart-safe + проактивный реклейм: мёртвый держатель lease (pid не жив)
|
||||
реклеймится reclaim_stale_lease; конвейер не встаёт навсегда (AC-A4).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A05
|
||||
type: unit
|
||||
description: >
|
||||
Anti-livelock defer: B defer-ится с available_at-задержкой и bounded бюджетом;
|
||||
исчерпание → эскалация (Blocked/alert), не бесконечный цикл (AC-A6).
|
||||
module: tests/test_orch026_merge_serialize.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A06
|
||||
type: unit
|
||||
description: >
|
||||
Условность/kill-switch: при выключенном флаге и для репо вне scope поведение
|
||||
merge/деплоя 1:1 как до ORCH-026 — no-op (AC-A7, AC-G2).
|
||||
module: tests/test_orch026_conditionality.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A07
|
||||
type: unit
|
||||
description: >
|
||||
Self-hosting safety: новая логика никогда не делает push/force-push в main;
|
||||
force только --force-with-lease на ветку задачи; STAGE_TRANSITIONS не изменены
|
||||
(AC-A5).
|
||||
module: tests/test_orch026_conditionality.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-A08
|
||||
type: integration
|
||||
description: >
|
||||
Сквозной сценарий: две задачи одного репо проходят deploy-staging→deploy; B не
|
||||
доходит до merge, пока A не в main; после A→done B ребейзится на свежий main
|
||||
(несёт код A) и мержится. main не теряет код A (AC-A1/AC-A2).
|
||||
module: tests/test_orch026_serialize_integration.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- Уровень B: декларативные зависимости ----------------
|
||||
- id: TC-B01
|
||||
type: unit
|
||||
description: >
|
||||
Чтение/декларация связи blocked-by из выбранного источника (Plane/БД/гибрид);
|
||||
связь корректно резолвится в depends_on_task_id (AC-B1). never-raise при
|
||||
недоступности источника → консервативно (нет связи или fail-closed по решению ADR).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B02
|
||||
type: unit
|
||||
description: >
|
||||
Гейт готовности (leaf-функция): задача с незавершённым depends-on НЕ ready;
|
||||
все depends-on в done → ready. Чистая логика, юнит-тестируемая (AC-B2).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B03
|
||||
type: unit
|
||||
description: >
|
||||
Детект циклов: A→B→A (и длиннее) детектируется детерминированно; ацикличный
|
||||
граф → циклов нет. Чистая функция (AC-B3).
|
||||
module: tests/test_orch026_dep_cycles.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B04
|
||||
type: unit
|
||||
description: >
|
||||
Цикл → set_issue_blocked + alert (Telegram/Plane), без падения воркера и без
|
||||
блокировки потока других задач (AC-B3, AC-G1).
|
||||
module: tests/test_orch026_dep_cycles.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B05
|
||||
type: unit
|
||||
description: >
|
||||
claim_next_job не клеймит заблокированную задачу (не занимает слот
|
||||
max_concurrency); как только depends-on done — задача становится claimable (AC-B2).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B06
|
||||
type: unit
|
||||
description: >
|
||||
Видимость: заблокированная задача отражается в Plane-статусе Blocked и/или
|
||||
строке ожидания Telegram-карточки; инвариант «одна карточка на задачу» сохранён
|
||||
(AC-B4). notifications never-raise / silent.
|
||||
module: tests/test_orch026_dep_visibility.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B07
|
||||
type: unit
|
||||
description: >
|
||||
reconciler F-1 НЕ разблокирует задачу, заблокированную по зависимости (как для
|
||||
Blocked/Needs-Input); reaper не реапит корректно ожидающую (AC-B5).
|
||||
module: tests/test_orch026_task_deps.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-B08
|
||||
type: integration
|
||||
description: >
|
||||
Сквозной сценарий: B объявлена blocked-by A; при постановке в очередь B не
|
||||
стартует, пока A не done; после A→done воркер запускает B. Telegram/Plane
|
||||
показывают Blocked у B до разблокировки (AC-B1/B2/B4).
|
||||
module: tests/test_orch026_deps_integration.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- Общие / миграция / регресс ----------------
|
||||
- id: TC-G01
|
||||
type: unit
|
||||
description: >
|
||||
Аддитивная миграция БД (если выбран вариант с таблицей/колонкой): идемпотентна,
|
||||
безопасна на существующей БД с данными, не меняет существующие колонки (AC-G4).
|
||||
module: tests/test_orch026_migration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-G02
|
||||
type: unit
|
||||
description: >
|
||||
Наблюдаемость GET /queue: новый блок (заблокированные задачи / держатель lease /
|
||||
defer-счётчики / циклы) присутствует и read-only; не источник истины.
|
||||
module: tests/test_orch026_queue_observability.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-G03
|
||||
type: integration
|
||||
description: >
|
||||
Регресс: полный pytest tests/ -q остаётся зелёным — merge-gate (ORCH-043),
|
||||
merge-verify (ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не
|
||||
деградировали (AC-G5).
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,226 @@
|
||||
# ADR-001: Сериализация merge/деплоя внутри репо (A) + декларативные зависимости задач (B)
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator (self-hosting) · **Стадия:** architecture
|
||||
**Статус:** Accepted
|
||||
**Связи:** дополняет ORCH-043 (merge-gate), ORCH-065 (merge-lease + reclaim), ORCH-073/071
|
||||
(merge-verify, SHA-in-main), ORCH-1 (очередь). Глобальный ADR — `adr/adr-0015`.
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
ORCH-026 закрывает **первопричину** эрозии `main` 08.06 (некоординированный параллелизм
|
||||
задач одного репо: ветки от устаревшего `main`, фантом-merge затирает соседа) и попутно вводит
|
||||
исходный скоуп — декларативные зависимости задач (B ждёт A). Требования — `01-brd.md`,
|
||||
`02-trz.md`; PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
|
||||
Ключевое наблюдение архитектора: **бо́льшая часть инфраструктуры для Уровня A уже существует** и
|
||||
её НЕ нужно переписывать:
|
||||
|
||||
- **merge-lease** (ORCH-065, `src/merge_gate.py`): per-repo файловый лиз
|
||||
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный реклейм
|
||||
мёртвого/устаревшего держателя (`reclaim_stale_lease`, `pid_alive`). Restart-safe, per-repo.
|
||||
- **merge-gate** (ORCH-043, `check_branch_mergeable`): на ребре `deploy-staging → deploy`
|
||||
захватывает лиз, при необходимости ребейзит, держит лиз до фактического merge.
|
||||
- **defer-механизм** (`_handle_merge_gate_defer`): `merge-lock busy` → повторная постановка
|
||||
deployer'а через `available_at`, bounded `merge_defer_max_attempts` → эскалация (Blocked+alert).
|
||||
- **окно лиза** уже простирается от `deploy-staging → deploy` до release на одном из событий:
|
||||
PR-merged webhook (`gitea.py`), `deploy→done` (`stage_engine.py`), откат, проактивный реклейм.
|
||||
Для self-hosting `done` достигается ТОЛЬКО после `verify_merged_to_main` (SHA-in-main, ORCH-073).
|
||||
|
||||
Таким образом окно сериализации A-1 («merge → main-updated») **структурно уже реализовано**:
|
||||
пока A не подтверждена в `main` (для self — SHA-in-main → `done`), лиз держится, и B того же
|
||||
репо на своём merge-gate получает `merge-lock busy` → defer. Открытый вопрос BRD §4.3 (граница
|
||||
окна для self) решается так: **окно = от acquire до release; release-события не меняем**. Для
|
||||
non-self репо граница — PR-merged webhook; для self — `deploy→done` (= SHA-in-main подтверждён).
|
||||
|
||||
Что реально **отсутствует** для Уровня A:
|
||||
|
||||
- **A-2: безусловный proactive pre-merge rebase.** Сейчас `check_branch_mergeable` ребейзит
|
||||
ТОЛЬКО если `branch_is_behind_main` (⇔ `origin/main` не предок HEAD). AC-A2 требует, чтобы
|
||||
rebase вызывался **всегда** перед merge — детерминированный структурный анти-фантом на уровне
|
||||
планировщика, не зависящий от точности ancestor-проверки.
|
||||
|
||||
Для Уровня B инфраструктуры нет вовсе: очередь `jobs` (ORCH-1) плоская (FIFO по `id` +
|
||||
`available_at` + `max_concurrency`), выразить «B ждёт A» нельзя.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Уровень A — сериализация merge/деплоя (минимально-инвазивно, переиспользуя ORCH-043/065)
|
||||
|
||||
**A-1/A-3/A-4 (окно сериализации) — без изменений механизма.** Окно сериализации обеспечивается
|
||||
существующим merge-lease: захват в `check_branch_mergeable`, удержание до release. Подтверждаем и
|
||||
фиксируем в доке, что release-события (`PR-merged` / `deploy→done` / откат / `reclaim_stale_lease`)
|
||||
формируют окно «merge → main-updated». Кросс-репо параллелизм сохранён автоматически (лиз —
|
||||
per-repo файл). Restart-safe и анти-залипание — за счёт ORCH-065 reclaim. **Кода-изменений нет.**
|
||||
|
||||
**A-2 (безусловный pre-merge rebase) — новое поведение, флаг `premerge_rebase_always`.**
|
||||
|
||||
- В `check_branch_mergeable` (`src/qg/checks.py`), ПОД захваченным merge-lease: когда
|
||||
`settings.premerge_rebase_always` истинно (и merge-gate применим к репо), **пропустить
|
||||
short-circuit `branch_is_behind_main`** и **всегда** вызвать `merge_gate.auto_rebase_onto_main`.
|
||||
- `auto_rebase_onto_main` уже идемпотентен и дёшев на актуальной ветке: `git rebase origin/main`
|
||||
на не-отстающей ветке — no-op (rc 0, HEAD не меняется), последующий `push --force-with-lease`
|
||||
→ «Everything up-to-date» (тот же SHA, **CI не перезапускается, лишних коммитов нет**). На
|
||||
отстающей ветке — реальный догон. Текстовый конфликт → существующий контракт: `rebase --abort`
|
||||
→ откат на `development` (как ORCH-043). **Инвариант: никаких push/force-push в `main`** —
|
||||
единственная force-операция остаётся `--force-with-lease` на ветку задачи.
|
||||
- Когда флаг выключен → прежнее поведение (ребейз только при `branch_is_behind_main`),
|
||||
обратная совместимость 1:1 (AC-A7/AC-G2).
|
||||
- **Скоуп — общий с merge-gate:** реально только для `merge_gate_repos` (пусто → self-hosting
|
||||
`orchestrator`). Никакого нового scope-флага.
|
||||
|
||||
**A-5/A-6 (safety, anti-livelock) — без изменений.** `STAGE_TRANSITIONS`, `QG_CHECKS`,
|
||||
`Confirm Deploy` (ORCH-059), exit-коды хука, terminal-sync не трогаются. defer-бюджет —
|
||||
существующий `merge_defer_max_attempts` → Blocked+alert при исчерпании. Прод-контейнер не
|
||||
рестартится вне штатного `Confirm Deploy`.
|
||||
|
||||
### Уровень B — декларативные зависимости (новая инфраструктура)
|
||||
|
||||
**B-источник: гибрид с БД как источником истины для планировщика; флаг `task_deps_source`.**
|
||||
|
||||
Планировщик `claim_next_job` — горячий цикл, обслуживающий очередь ВСЕХ проектов из ОДНОГО
|
||||
инстанса. Он **обязан** быть offline-устойчивым и быстрым: сетевой запрос в Plane на каждый claim
|
||||
= при недоступности Plane встанет конвейер всех проектов (нарушение self-hosting safety). Поэтому:
|
||||
|
||||
- **Авторитетный для планировщика стор — локальная БД**, новая аддитивная таблица
|
||||
`job_deps(task_id, depends_on_task_id, created_at)` (детали — `08-data-requirements.md`).
|
||||
Связь хранится по `tasks.id` (стабильный локальный ключ). Зависимости — **только внутри одного
|
||||
репо** (v1; кросс-репо — non-goal, BRD §5).
|
||||
- **`task_deps_source = db | plane | hybrid`** (дефолт **`db`**): `db` — связи пишутся напрямую в
|
||||
`job_deps` (потребитель — декомпозиция эпиков ORCH-025); `plane` — связи читаются из Plane
|
||||
relations в `handle_work_item_created` и **кэшируются** в `job_deps`; `hybrid` — Plane как
|
||||
декларация + БД-кэш. Plane-ingestion — тонкий add-on за флагом; планировщик ВСЕГДА читает БД.
|
||||
|
||||
**B-2 (гейт планировщика) — SQL `NOT EXISTS`, без занятия слота `max_concurrency`.**
|
||||
|
||||
Гейт готовности выражается декларативно в `claim_next_job` (`src/db.py`): задача claimable, если
|
||||
у неё нет ни одной незавершённой зависимости. Когда `settings.task_deps_enabled` — к существующему
|
||||
SELECT добавляется условие:
|
||||
|
||||
```sql
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM job_deps d
|
||||
JOIN tasks t ON t.id = d.depends_on_task_id
|
||||
WHERE d.task_id = j.task_id AND t.stage != 'done'
|
||||
)
|
||||
```
|
||||
|
||||
Это: (1) **не занимает слот** — job просто не выбирается, агент не запускается (AC-B2);
|
||||
(2) restart-safe (чистая БД); (3) never-raise (это SQL); (4) при пустой `job_deps` —
|
||||
инертно (нулевая регрессия, AC-G2); (5) при выключенном `task_deps_enabled` условие НЕ
|
||||
добавляется → запрос 1:1 как в ORCH-1. Как только все зависимости достигают `stage='done'`,
|
||||
задача автоматически становится claimable.
|
||||
|
||||
Чистая leaf-логика «готова ли задача» выносится в новый модуль `src/task_deps.py`:
|
||||
`is_task_ready(task_id) -> (bool, waiting_on: list[str])` (never-raise) — для реконсилятора,
|
||||
карточки и `/queue` (SQL в `claim_next_job` — горячий путь, дублирует ту же семантику).
|
||||
|
||||
**B-3 (детект дедлоков) — DFS, чистая функция.**
|
||||
|
||||
`task_deps.detect_cycle(task_id) -> list[int] | None` — обход графа `job_deps` (внутри репо),
|
||||
детерминированный, юнит-тестируемый, never-raise. Запускается: (1) при вставке связи
|
||||
(`add_dependency`) — цикл отклоняется/алертится сразу (лучший UX); (2) backstop-проход в тике
|
||||
`reconciler` (на случай связей, добавленных в обход). Цикл → `set_issue_blocked(work_item_id)` +
|
||||
Telegram/Plane alert с перечислением цикла. SQL-гейт B-2 сам по себе никогда не выберет задачу в
|
||||
цикле (её зависимости не достигнут `done`) — детектор делает это **видимым**, а не молчаливым
|
||||
вечным ожиданием (AC-B3). Поток остальных задач не блокируется.
|
||||
|
||||
**B-4 (видимость).**
|
||||
|
||||
- Нормальное ожидание (B ждёт A, A в работе — транзиентно и ожидаемо): строка в Telegram-карточке
|
||||
«⏳ ждёт ORCH-NNN» через `notifications.update_task_tracker`, never-raise/silent. **Plane Blocked
|
||||
при нормальном ожидании НЕ ставим** — иначе флаппинг Blocked на каждом коротком ожидании.
|
||||
- Дедлок/цикл (B-3): `set_issue_blocked` (Plane `Blocked`) + alert. Это «и/или» из AC-B4.
|
||||
- Инвариант «одна карточка на задачу» сохранён (ORCH-042/067).
|
||||
|
||||
**B-5 (совместимость reconciler/reaper).**
|
||||
|
||||
- `reconciler` F-1 не должен «разблокировать» dep-заблокированную задачу мимо её зависимостей.
|
||||
В фильтр пригодности reconciler добавляется проверка `task_deps.is_task_ready` (по образцу
|
||||
`reconcile_skip_blocked_enabled`, ORCH-060): не готова → skip.
|
||||
- `reaper` сканирует **`running`** jobs; dep-заблокированный job остаётся `queued` (его не
|
||||
клеймят) → reaper его не трогает по построению. Фиксируем в доке.
|
||||
|
||||
**Наблюдаемость (TRZ §4):** блок `task_deps` в снимке `GET /queue` (read-only, по образцу
|
||||
`reconcile`/`reaper`): кол-во заблокированных задач, держатель merge-lease, defer-счётчики,
|
||||
обнаруженные циклы. Никогда не источник решений.
|
||||
|
||||
### Конфигурация (`src/config.py`)
|
||||
|
||||
| Флаг | Дефолт | Назначение |
|
||||
|------|--------|-----------|
|
||||
| `premerge_rebase_always` | `True` | Уровень A: безусловный pre-merge rebase под лизом. Скоуп — `merge_gate_repos`. Kill-switch (`False` → ребейз только при behind, как ORCH-043). |
|
||||
| `task_deps_enabled` | `True` | Уровень B: глобальный kill-switch гейта зависимостей. `False` → `claim_next_job` 1:1 как ORCH-1. Инертно при пустой `job_deps`. |
|
||||
| `task_deps_source` | `"db"` | Источник деклараций: `db`\|`plane`\|`hybrid`. Планировщик всегда читает БД-кэш. |
|
||||
|
||||
Дефолты следуют конвенции репо (`*_enabled=True` + kill-switch), при этом обе фичи инертны без
|
||||
данных (нет деклараций / нет применимых репо) → нулевая регрессия для enduro-trails.
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (и почему отвергнуты)
|
||||
|
||||
1. **Уровень A — отдельный глобальный per-repo merge-lock или FIFO merge-queue.** Дублировал бы
|
||||
уже существующий merge-lease (ORCH-065), вводил второй механизм сериализации с риском
|
||||
рассинхрона. Отвергнуто: BRD §4.2 требует минимально-инвазивного решения, переиспользующего
|
||||
ORCH-065/043. Окно лиза уже даёт сериализацию.
|
||||
|
||||
2. **Уровень A — расширять release-точки лиза (держать до отдельного `main-updated`-события).**
|
||||
Не требуется: для self `done` ⇔ SHA-in-main (ORCH-073), для non-self — PR-merged webhook;
|
||||
окно уже корректно. Доп. событие усложнило бы reclaim без выигрыша.
|
||||
|
||||
3. **Уровень B — Plane relations как источник истины планировщика.** Сетевой запрос в горячем
|
||||
цикле claim; при недоступности Plane встаёт очередь всех проектов (self-hosting risk).
|
||||
Отвергнуто; Plane оставлен опциональным источником **декларации** (`task_deps_source=plane`),
|
||||
но планировщик читает только БД-кэш.
|
||||
|
||||
4. **Уровень B — гейт зависимостей в воркере (`_drain_once`) поверх `claim_next_job`.** Пришлось
|
||||
бы клеймить job, обнаруживать незавершённую зависимость и re-queue’ить — churn, расход attempts,
|
||||
гонки. SQL `NOT EXISTS` в самом `claim_next_job` чище: job просто не выбирается, слот свободен.
|
||||
|
||||
5. **Уровень B — поле/JSON в `tasks` вместо таблицы.** Таблица `job_deps` нормальна (M:N),
|
||||
индексируема, проще для DFS и `NOT EXISTS`. Поле в `tasks` потребовало бы парсинг-логики.
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы.**
|
||||
- Минимально-инвазивно: Уровень A — один флаг + снятие short-circuit; окно сериализации не
|
||||
переписывается. Переиспользует ORCH-043/065 целиком.
|
||||
- Уровень B — одно `NOT EXISTS` в `claim_next_job` + аддитивная таблица + leaf-модуль
|
||||
`task_deps.py`; `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки ORCH-071/058).
|
||||
- Обе фичи инертны без данных → нулевая регрессия для enduro-trails (AC-A7/AC-G2).
|
||||
- restart-safe (БД + файловый лиз), never-raise, kill-switch на каждую фичу.
|
||||
|
||||
**Минусы / ограничения.**
|
||||
- `premerge_rebase_always=True` добавляет (дешёвый, no-op на актуальной ветке) `rebase`+`push`
|
||||
на каждый self-merge. Цена — лишний git-вызов; компенсируется детерминизмом анти-фантома.
|
||||
- Уровень B v1 — только intra-repo зависимости; кросс-репо — follow-up (non-goal).
|
||||
- Гейт B-2 в `claim_next_job` слегка усложняет горячий SQL (один `NOT EXISTS`); защищён
|
||||
kill-switch и инертностью при пустой таблице.
|
||||
- `task_deps.py` цикл-детектор — новая поверхность; покрывается юнит-тестами (`04-test-plan.yaml`).
|
||||
|
||||
**Инварианты (не нарушать).**
|
||||
1. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
|
||||
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
|
||||
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
|
||||
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
|
||||
4. never-raise во всех новых функциях; restart-safe состояние; миграция БД только аддитивная.
|
||||
5. ORCH-026 **дополняет** рубежи ORCH-073, не заменяет.
|
||||
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.
|
||||
|
||||
**Места реализации (для developer).**
|
||||
- `src/qg/checks.py::check_branch_mergeable` — ветка `premerge_rebase_always`.
|
||||
- `src/db.py::claim_next_job` — условный `NOT EXISTS`-гейт; новые helpers `add_dependency`,
|
||||
`get_dependencies`, `job_deps` миграция в `init_db` (`CREATE TABLE IF NOT EXISTS`).
|
||||
- `src/task_deps.py` (новый, leaf) — `is_task_ready`, `detect_cycle`, snapshot для `/queue`.
|
||||
- `src/webhooks/plane.py::handle_work_item_created` — ingestion Plane relations (за `task_deps_source`).
|
||||
- `src/reconciler.py` — skip dep-заблокированных + backstop цикл-детект.
|
||||
- `src/notifications.py` — строка ожидания в карточке.
|
||||
- `src/config.py` — `premerge_rebase_always`, `task_deps_enabled`, `task_deps_source`.
|
||||
- Документация: `docs/architecture/README.md`, `CLAUDE.md` (если меняется поведение очереди),
|
||||
`CHANGELOG.md`, глобальный `adr/adr-0015`.
|
||||
65
docs/work-items/ORCH-026/08-data-requirements.md
Normal file
65
docs/work-items/ORCH-026/08-data-requirements.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 08 — Требования к схеме БД — ORCH-026
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
|
||||
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md` (Уровень B).
|
||||
|
||||
> Уровень A (сериализация merge/деплоя) — **БЕЗ изменения схемы БД** (merge-lease файловый,
|
||||
> `.merge-lease-<repo>.json`, ORCH-065). Изменения схемы касаются ТОЛЬКО Уровня B.
|
||||
|
||||
---
|
||||
|
||||
## Новая таблица `job_deps` (аддитивная)
|
||||
|
||||
Хранит декларативные зависимости «задача `task_id` ждёт задачу `depends_on_task_id`».
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS job_deps (
|
||||
task_id INTEGER NOT NULL, -- tasks.id зависимой задачи (B)
|
||||
depends_on_task_id INTEGER NOT NULL, -- tasks.id задачи-предшественника (A)
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (task_id, depends_on_task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
|
||||
```
|
||||
|
||||
### Поля
|
||||
| Поле | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `task_id` | INTEGER | `tasks.id` зависимой задачи (B). Не запускается, пока зависимости не `done`. |
|
||||
| `depends_on_task_id` | INTEGER | `tasks.id` предшественника (A). Терминальность — `tasks.stage = 'done'`. |
|
||||
| `created_at` | TEXT | Время декларации (диагностика). |
|
||||
|
||||
### Ключ и индексы
|
||||
- **PK `(task_id, depends_on_task_id)`** — идемпотентность вставки (повторная декларация связи —
|
||||
no-op через `INSERT OR IGNORE`), запрет дублей.
|
||||
- `idx_job_deps_task` — гейт планировщика (`NOT EXISTS ... WHERE d.task_id = j.task_id`).
|
||||
- `idx_job_deps_depends` — обратные рёбра для DFS цикл-детектора.
|
||||
|
||||
### Семантика готовности (источник истины планировщика)
|
||||
Задача `task_id` **готова к запуску** ⇔ нет ни одной строки `job_deps` для неё, чей
|
||||
`depends_on_task_id` указывает на задачу с `tasks.stage != 'done'`. Терминал — только `done`
|
||||
(совпадает с тем, как `get_active_tasks_for_reconcile` трактует терминальность).
|
||||
|
||||
### Связь по `task_id`, а не `work_item_id`
|
||||
`tasks.id` — стабильный локальный автоинкремент-ключ; `work_item_id`/`plane_id` могут
|
||||
ресолвиться/коллизиться (см. `ensure_unique_work_item_id`). FK логический (без `REFERENCES`,
|
||||
как у `jobs.task_id`) — не блокирует аддитивную миграцию и удаление строк tasks (которого в
|
||||
конвейере нет). Зависимости — **только intra-repo** (v1); кросс-репо рёбра не создаются.
|
||||
|
||||
---
|
||||
|
||||
## Миграция (AC-G4)
|
||||
|
||||
- Выполняется в `src/db.py::init_db` рядом с прочими: **только** `CREATE TABLE IF NOT EXISTS` +
|
||||
`CREATE INDEX IF NOT EXISTS`. **Идемпотентно**, restart-safe, безопасно на живой общей прод-БД.
|
||||
- **Существующие колонки/таблицы (`jobs`, `tasks`, `agent_runs`, `events`) НЕ изменяются** →
|
||||
данные enduro-trails не затронуты.
|
||||
- Откат фичи — флагом `task_deps_enabled=False` (таблица остаётся, гейт не применяется); сама
|
||||
таблица деструктивно не удаляется.
|
||||
|
||||
## Что НЕ меняется
|
||||
- Схема `jobs` (включая `available_at`, `pid`, `attempts`/`transient_attempts`) — без изменений;
|
||||
defer Уровня A/B переиспользует существующий `available_at`-механизм.
|
||||
- Схема `tasks` — без изменений (видимость через существующие `tracker_message_id` и Plane Blocked).
|
||||
- merge-lease — файловый, вне БД.
|
||||
17
docs/work-items/ORCH-026/10-tech-risks.md
Normal file
17
docs/work-items/ORCH-026/10-tech-risks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 10 — Технические риски — ORCH-026
|
||||
|
||||
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
|
||||
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md`.
|
||||
|
||||
| # | Риск | Уровень | Митигация |
|
||||
|---|------|---------|-----------|
|
||||
| R-1 | **Гейт `NOT EXISTS` в `claim_next_job` (горячий путь всех проектов) содержит баг → встаёт очередь ВСЕХ проектов** (self-hosting групповой риск). | Высокий | Условие добавляется ТОЛЬКО при `task_deps_enabled`; инертно при пустой `job_deps` (нулевая регрессия); kill-switch `task_deps_enabled=False` мгновенно возвращает поведение ORCH-1; интеграционный тест «пустые deps ⇒ FIFO 1:1» (AC-G2). |
|
||||
| R-2 | **Безусловный `premerge_rebase_always` делает лишний `push --force-with-lease` → ложный перезапуск CI / новые коммиты.** | Низкий | На актуальной ветке `rebase origin/main` — no-op (HEAD не меняется), push → «Everything up-to-date» (тот же SHA, CI не триггерится). Подтвердить тестом, что SHA не меняется на уже-актуальной ветке. |
|
||||
| R-3 | **Дедлок по циклической зависимости → задача молча ждёт вечно.** | Средний | DFS-детектор `detect_cycle` при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert с перечислением цикла (AC-B3); SQL-гейт не выбирает задачу в цикле, детектор делает это видимым. |
|
||||
| R-4 | **Livelock: B бесконечно defer’ится на `merge-lock busy`.** | Низкий | Существующий bounded-бюджет `merge_defer_max_attempts` → Blocked+alert (ORCH-043, без изменений). |
|
||||
| R-5 | **Залипший merge-lease после смерти держателя → конвейер репо встаёт навсегда.** | Средний | Переиспользуется ORCH-065: `reclaim_stale_lease` (мёртвый `pid` / TTL `merge_lock_timeout_s`) + holder-aware release. Restart-safe (AC-A4). |
|
||||
| R-6 | **Plane relations недоступны/неверно смаплены при `task_deps_source=plane`.** | Средний | Планировщик читает ТОЛЬКО БД-кэш `job_deps`; Plane-ingestion — best-effort, never-raise; дефолт `task_deps_source=db` не зависит от Plane. |
|
||||
| R-7 | **reconciler «разблокирует» dep-заблокированную задачу мимо её зависимостей.** | Средний | В фильтр reconciler добавляется `is_task_ready` (паттерн ORCH-060 skip-Blocked); reaper трогает только `running` — dep-блок остаётся `queued` (AC-B5). |
|
||||
| R-8 | **Миграция БД повреждает общую прод-БД (данные enduro-trails).** | Низкий | Только аддитивно: `CREATE TABLE/INDEX IF NOT EXISTS`; существующие колонки не меняются; идемпотентно (AC-G4). |
|
||||
| R-9 | **Self-hosting: изменения требуют рестарта прод-контейнера вне `Confirm Deploy`.** | Высокий (если нарушено) | Все изменения — обычный код, проходят `deploy-staging` (8501) → `Confirm Deploy` (ORCH-059). `STAGE_TRANSITIONS`/`QG_CHECKS` не трогаются; никакого внеочередного рестарта (AC-A5). |
|
||||
| R-10 | **Конфликт точек интеграции A (merge-gate) и B (постановка в очередь).** | Низкий | Разные точки конвейера: B гейтит claim job (вход), A гейтит merge на ребре `deploy-staging→deploy`. Независимы; покрыть интеграционным тестом совместной работы (BRD §4.4). |
|
||||
47
docs/work-items/ORCH-026/12-review.md
Normal file
47
docs/work-items/ORCH-026/12-review.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-026
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-026
|
||||
|
||||
## Summary
|
||||
ORCH-026 реализует два уровня по ADR-001: **Уровень A** — сериализация merge/deploy внутри одного репо (переиспользует merge-lease ORCH-043/065 + единственная новая логика — безусловный pre-merge rebase под флагом `premerge_rebase_always`) и **Уровень B** — декларативные зависимости задач (аддитивная таблица `job_deps`, гейт `NOT EXISTS` в `claim_next_job`, leaf-модуль `src/task_deps.py`). Реализация минимально-инвазивна, строго соответствует ТЗ и ADR, обе фичи условны (kill-switch) и инертны без данных. Все 16 критериев приёмки выполнены. Полный прогон `pytest tests/ -q` — **991 passed**, из них 50 новых ORCH-026-тестов зелёные. Документация обновлена в том же PR. **APPROVED.**
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
### P3 — Nice to have
|
||||
- [ ] PR-ветка несёт коммиты ORCH-073 (`main` ещё не получил merge #77, merge-base = `77abfb3`). Это ожидаемо по топологии (ORCH-026 (B) построен поверх уже отревьюенного предшественника ORCH-073 (A): у ORCH-073 есть собственные `12-review.md`/`13-test-report.md`/`14-deploy-log.md`) и фактически демонстрирует саму фичу A (rebase B на код A). Не блокирует; при merge в `main` приедут оба набора изменений — это корректно.
|
||||
|
||||
## Соответствие ТЗ и ADR
|
||||
- **Уровень A (AC-A1…A7):** окно сериализации обеспечено существующим merge-lease без нового механизма (ADR §A-1/A-3/A-4). A-2 — `check_branch_mergeable` (`src/qg/checks.py`) под лизом при `premerge_rebase_always=True` всегда вызывает `auto_rebase_onto_main`, снимая short-circuit `branch_is_behind_main`; kill-switch off → поведение ORCH-043 1:1. `STAGE_TRANSITIONS`/`QG_CHECKS`/`Confirm Deploy` не тронуты — соответствует инвариантам §9. Никаких push/force в `main` (только `--force-with-lease` ветки).
|
||||
- **Уровень B (AC-B1…B5):** гейт `NOT EXISTS (job_deps JOIN tasks WHERE stage!='done')` в `claim_next_job` (`src/db.py`) — job не выбирается, слот `max_concurrency` не занимается; при выключенном флаге / пустой таблице clause не добавляется (нулевая регрессия). `task_deps.py` — чистый leaf: `is_task_ready` (fail-open), итеративный WHITE/GREY/BLACK DFS-детектор циклов (защита от recursion-limit на проде), `handle_cycle` (Blocked+alert), `declare_dependency`, `ingest_plane_relations` (только `plane|hybrid`, дефолт `db` не ходит в сеть на горячем пути). reconciler F-1 получил Guard 3 (skip dep-заблокированных + backstop детект цикла); reaper не тронут (сканирует `running`).
|
||||
- **Общие (AC-G1…G5):** контракт never-raise выдержан во всех новых функциях (try/except, консервативная деградация). Миграция строго аддитивна — `CREATE TABLE/INDEX IF NOT EXISTS`, без `REFERENCES`, схема `tasks`/`jobs` не изменена (AC-G4 OK на живой общей БД). Наблюдаемость — read-only блок `task_deps` в `GET /queue`. Реализация в точности по местам, указанным в ADR §«Места реализации».
|
||||
|
||||
## Качество кода
|
||||
- Docstrings на всех публичных функциях, явно документирован контракт fail-open/fail-closed.
|
||||
- SQL-гейт безопасен: `dep_gate` — константная строка (нет инъекции), таблица `job_deps` гарантированно создана в `init_db`.
|
||||
- Переменные `plane_id`/`plane_project_id`/`task_id` в `start_pipeline` — в области видимости (проверено).
|
||||
- Тесты содержательные: миграция, conditionality (kill-switch), циклы, видимость, observability, интеграция сериализации и зависимостей.
|
||||
|
||||
## Документация — обновлена (golden source)
|
||||
Проверено: код в `src/` изменён → документация обновлена В ТОМ ЖЕ PR (разнесена по pipeline-коммитам ветки, что нормально):
|
||||
- `docs/architecture/README.md` — разделы про очередь (`claim_next_job`-гейт), pre-merge rebase, «Зависимости задач: B ждёт A», `job_deps`, наблюдаемость (architect-коммит `f8ec1c2`). ✓
|
||||
- `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md` + глобальный `docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md`. ✓
|
||||
- `CLAUDE.md` — паспорт (очередь/сериализация). ✓
|
||||
- `CHANGELOG.md` — запись `## [Unreleased]`. ✓
|
||||
- `.env.example` — `ORCH_PREMERGE_REBASE_ALWAYS`/`ORCH_TASK_DEPS_ENABLED`/`ORCH_TASK_DEPS_SOURCE`. ✓
|
||||
- `08-data-requirements.md` — таблица `job_deps`. ✓
|
||||
|
||||
Документация = golden source: требование выполнено.
|
||||
75
docs/work-items/ORCH-026/13-test-report.md
Normal file
75
docs/work-items/ORCH-026/13-test-report.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-026
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-026
|
||||
|
||||
Задача: «Управление зависимостями задач (B ждёт A) в очереди» + сериализация merge/деплоя
|
||||
одного репо. Ветка `feature/ORCH-026-b-a`. Review-вердикт: **APPROVED** (`12-review.md`).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: `feature/ORCH-026-b-a` (HEAD `aaa4829`)
|
||||
- Прод-оркестратор (8500): `/health` → `{"status":"ok"}` (не перезапускался, self-hosting инвариант соблюдён)
|
||||
- Дата: 2026-06-08
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
### Уровень A — сериализация merge/деплоя
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-A01 | Proactive pre-merge rebase (всегда, даже когда не behind) | `test_orch026_premerge_rebase::test_always_rebases_even_when_not_behind` | PASS |
|
||||
| TC-A02 | Расширенное окно merge-lease, defer не откат; holder-aware release | `test_orch026_merge_serialize::test_second_task_same_repo_defers_not_rollback`, `test_holder_aware_release_keeps_foreign_lease` | PASS |
|
||||
| TC-A03 | Сериализация строго per-repo (orchestrator ≠ enduro-trails) | `test_orch026_merge_serialize::test_serialization_is_strictly_per_repo` | PASS |
|
||||
| TC-A04 | Restart-safe + реклейм мёртвого держателя lease | `test_orch026_merge_serialize::test_dead_holder_lease_is_reclaimed`, `test_stale_lease_age_reclaimed_on_acquire` | PASS |
|
||||
| TC-A05 | Anti-livelock defer: bounded бюджет, эскалация | `test_orch026_merge_serialize::test_defer_budget_is_bounded` | PASS |
|
||||
| TC-A06 | Условность/kill-switch: off + out-of-scope = no-op | `test_orch026_conditionality::test_out_of_scope_repo_is_noop_even_with_flag_on`, `test_premerge_rebase::test_flag_off_short_circuits_like_orch043` | PASS |
|
||||
| TC-A07 | Self-hosting safety: только `--force-with-lease` на ветку, STAGE_TRANSITIONS не тронуты | `test_orch026_conditionality::test_premerge_only_force_with_lease_on_branch`, `test_stage_transitions_unchanged` | PASS |
|
||||
| TC-A08 | Сквозной сценарий сериализации merge-окна | `test_orch026_serialize_integration::test_serialized_merge_window` | PASS |
|
||||
|
||||
### Уровень B — декларативные зависимости
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-B01 | Декларация/резолв blocked-by; never-raise при недоступности | `test_orch026_task_deps::test_add_dependency_declares_and_resolves`, `test_add_dependency_never_raises_on_bad_input` | PASS |
|
||||
| TC-B02 | Гейт готовности: незавершённый depends-on → не ready; все done → ready | `test_orch026_task_deps::test_is_task_ready_blocked_then_ready`, `test_is_task_ready_no_deps_is_ready` | PASS |
|
||||
| TC-B03 | Детект циклов A→B→A и длиннее; ацикличный → нет | `test_orch026_dep_cycles::test_detect_two_node_cycle`, `test_detect_longer_cycle`, `test_acyclic_graph_has_no_cycle`, `test_detect_cycle_never_raises_on_garbage` | PASS |
|
||||
| TC-B04 | Цикл → Blocked + alert без падения воркера | `test_orch026_dep_cycles::test_handle_cycle_blocks_and_alerts`, `test_handle_cycle_never_raises_when_notify_fails` | PASS |
|
||||
| TC-B05 | claim_next_job не клеймит заблокированную (слот свободен), разблокируется при done | `test_orch026_task_deps::test_claim_skips_dep_blocked_job`, `test_claim_prefers_unblocked_job_over_blocked` | PASS |
|
||||
| TC-B06 | Видимость: строка ожидания в карточке; never-raise рендер | `test_orch026_dep_visibility::test_blocked_task_shows_waiting_line`, `test_render_never_raises_on_dep_error` | PASS |
|
||||
| TC-B07 | reconciler F-1 не разблокирует dep-заблокированную | `test_orch026_task_deps::test_reconciler_skip_helper_honours_block` | PASS |
|
||||
| TC-B08 | Сквозной: B стартует только после A→done; multiple predecessors | `test_orch026_deps_integration::test_b_waits_for_a_then_runs`, `test_multiple_predecessors_all_must_be_done`, `test_ingest_plane_relations_writes_db` | PASS |
|
||||
|
||||
### Общие / миграция / регресс
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-G01 | Аддитивная миграция job_deps: идемпотентна, данные сохранены | `test_orch026_migration::test_job_deps_table_created`, `test_job_deps_indices_created`, `test_migration_idempotent_and_preserves_data` | PASS |
|
||||
| TC-G02 | Наблюдаемость GET /queue: read-only блок task_deps | `test_orch026_queue_observability::test_queue_endpoint_includes_task_deps`, `test_snapshot_*` | PASS |
|
||||
| TC-G03 | Регресс: полный pytest зелёный | `tests/` (991 passed) | PASS |
|
||||
|
||||
## Smoke test API (прод 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → активные задачи отдаются, ORCH-026 (id 58) в стадии `testing` — OK
|
||||
- `GET /queue` → counts/resilience/reconcile/reaper/merge_verify читаются; брейкер `closed`, preflight OK — OK
|
||||
- Примечание: блок `task_deps` в `/queue` прода 8500 ОТСУТСТВУЕТ — ожидаемо: прод-контейнер несёт текущую задеплоенную версию, ORCH-026 ещё не выкатан (self-hosting, деплой на поздних стадиях). Фича наблюдаемости верифицирована in-branch тестом `test_queue_endpoint_includes_task_deps` (PASS) через TestClient на коде ветки.
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
tests/test_orch026_*.py — 50 passed, 1 warning in 1.56s
|
||||
tests/ — 991 passed, 1 warning in 26.52s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, предсуществующий, не относится к ORCH-026)
|
||||
|
||||
## Покрытие критериев приёмки (03-acceptance-criteria.md)
|
||||
Все 16 критериев (AC-A1…A7, AC-B1…B5, AC-G1…G5) покрыты прохождением соответствующих TC и
|
||||
подтверждены review-вердиктом APPROVED. Регрессии merge-gate (ORCH-043), merge-verify
|
||||
(ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не обнаружено.
|
||||
|
||||
## Итог
|
||||
**PASS** — 50/50 новых ORCH-026-тестов зелёные, полный регресс 991 passed, smoke API OK,
|
||||
прод-контейнер не затронут. Задача готова к переходу на `deploy-staging`.
|
||||
12
docs/work-items/ORCH-026/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-026/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-026
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
34
docs/work-items/ORCH-026/15-staging-log.md
Normal file
34
docs/work-items/ORCH-026/15-staging-log.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T16:14:11+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. Exit code 0 → advance.
|
||||
|
||||
Canonical run (ORCH-048, ADR-001) inside the live staging container:
|
||||
|
||||
```
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
## Result: 8/10 checks PASS
|
||||
|
||||
- **Block A (SMOKE):** A1 /health, A2 /queue, A3 ORCH_STAGING=true — all PASS.
|
||||
- **Block B (ACCESS):** B4 Plane sandbox (R), B5 Gitea orchestrator-sandbox (R+push), B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
|
||||
- **Block C (E2E, stub):** C7 create issue, C8 trigger pipeline — PASS.
|
||||
|
||||
REAL failed: **none** — all pipeline checks green.
|
||||
|
||||
## INFRA-WAIVED (ORCH-061)
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
C9a/C9b are the two known sandbox-infra-only checks (depend on SANDBOX bot accounts being members of the sandbox Plane project, not on the pipeline). They are tolerated because every REAL check is green; the script printed `INFRA-WAIVED:` and exited 0 (fail-closed semantics preserved: any REAL failure would still yield exit 1).
|
||||
12
docs/work-items/ORCH-073/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-073/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-073
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
7
docs/work-items/ORCH-074/00-business-request.md
Normal file
7
docs/work-items/ORCH-074/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-52a: фикс модели/эффорта агентов (мёртвый frontmatter → routing+effort)
|
||||
|
||||
Work Item ID: ORCH-074
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
89
docs/work-items/ORCH-074/01-brd.md
Normal file
89
docs/work-items/ORCH-074/01-brd.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# BRD — ORCH-074: фикс модели агентов (мёртвый frontmatter → валидация имени)
|
||||
|
||||
Work Item ID: ORCH-074
|
||||
Эпик: ORCH-052 (слой 3), под-задача ORCH-52a
|
||||
Приоритет: **urgent**
|
||||
Тип: доработка механизма выбора модели агентов (self-modifying).
|
||||
|
||||
## 0. История ревизий
|
||||
|
||||
- **rev.1 (08.06):** первичный пакет аналитики по фиксированному скоупу Славы.
|
||||
- **rev.2 (08.06, текущая):** задача возвращена стейкхолдером в In Progress.
|
||||
Проверены последние комментарии и описание issue в Plane — НОВЫХ субстантивных
|
||||
ответов/изменений скоупа нет (только bot-комменты + служебный маркер
|
||||
«Агент перезапущен с ответами стейкхолдера»). Скоуп остаётся прежним
|
||||
(G1 + G2 + опц. G4; G3 снят; эффорт не трогаем). Пакет переподтверждён против
|
||||
фактического кода (`launcher.py`, `config.py`); уточнён код-факт по G4: fallback
|
||||
читается напрямую на `launcher.py:374` мимо `resolve_agent_model`, поэтому
|
||||
валидация G2 должна покрыть и fallback (детали — ТЗ §4, AC-5, TC-11).
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Каркас выбора модели агентов реализован в ORCH-041 и **работает корректно**:
|
||||
`src/agents/launcher.py::resolve_agent_model(agent, project_id)` резолвит модель
|
||||
по приоритету project-override → `ORCH_AGENT_MODEL_<AGENT>` → `agent_model_default`
|
||||
→ CLI-дефолт. Все 6 агентов сейчас резолвятся в `claude-opus-4-8` (через
|
||||
`agent_model_default`).
|
||||
|
||||
Аудит кода (08.06) выявил два дефекта данных/валидации (НЕ дефект механизма):
|
||||
|
||||
- **P1. Лживый/мёртвый `model:` во frontmatter `.openclaw/agents/*.md`.**
|
||||
Все 6 промптов содержат `model:` в YAML-frontmatter:
|
||||
`claude-sonnet-4-6` (analyst, developer, tester, deployer) и
|
||||
`claude-opus-4-7` (architect, reviewer). launcher **НЕ читает** frontmatter
|
||||
`model:` — это мёртвая декларация, которая лжёт о реально используемой модели
|
||||
и нарушает принцип «документация = golden source». Мина: если кто-то «починит»
|
||||
launcher читать frontmatter → все агенты молча упадут на устаревшие модели.
|
||||
|
||||
- **P2. Нет валидации ИМЕНИ модели.** В отличие от effort (есть `VALID_EFFORTS`-гард,
|
||||
невалидный effort логируется и дропается), имя модели не валидируется. Опечатка
|
||||
в `agent_model_*` / project-override → `--model <мусор>` → CLI падает или тихо
|
||||
деградирует. Нарушение принципа never-break.
|
||||
|
||||
## 2. Решение Славы (08.06) — фиксированный скоп
|
||||
|
||||
> G3 model-routing **НЕ включаем** — ВСЕ 6 агентов остаются на `claude-opus-4-8`.
|
||||
> Скоп: **G1** (убрать лживый `model:` из frontmatter) + **G2** (валидация имени
|
||||
> модели, never-break) + **опц. G4** (`fallback_model` — на усмотрение архитектора,
|
||||
> НЕ routing). **Эффорт НЕ трогать.** AC-4 (routing) снят.
|
||||
|
||||
## 3. Бизнес-цели
|
||||
|
||||
| ID | Цель | Драйвер |
|
||||
|----|------|---------|
|
||||
| G1 | Устранить лживый frontmatter: убрать `model:` из всех 6 `.openclaw/agents/*.md`. config — единственный источник правды модели. | Наблюдаемость (frontmatter не лжёт) |
|
||||
| G2 | Добавить валидацию имени модели: невалидное имя → лог + откат на default, никогда не передаётся в `--model`. | Надёжность (never-break) |
|
||||
| G4 | (опц., решает архитектор) Задать `agent_fallback_model` для страховки доступности. | Надёжность (availability) |
|
||||
|
||||
## 4. Не-цели (явно вне скоупа)
|
||||
|
||||
- **G3 routing НЕ включаем.** Все 6 агентов остаются `claude-opus-4-8`. AC-4 снят.
|
||||
- **Эффорт НЕ трогать** — уже корректно настроен (`thinking → high`, `tester/deployer → medium`).
|
||||
- **Не менять resolve-механизм ORCH-041** — он корректен. Меняются только данные
|
||||
(frontmatter, опц. config) + добавляется валидация.
|
||||
- **Не трогать non-self поведение** — per-project override (`projects.py agent_models`)
|
||||
для enduro-trails остаётся рабочим.
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
|
||||
- **Owner (Слава)** — зафиксировал скоп; деплой через штатный «Confirm Deploy».
|
||||
- **Агенты оркестратора** — потребители resolve-механизма (self-hosting).
|
||||
- **Проект enduro-trails** — НЕ должен пострадать (общий инстанс/БД/очередь).
|
||||
|
||||
## 6. Риски и инварианты
|
||||
|
||||
- **Self-hosting:** изменение применяется к БУДУЩИМ запускам агентов. НЕ ломать
|
||||
текущий конвейер; не ронять прод-контейнер. Деплой только через «Confirm Deploy».
|
||||
- **never-break:** невалидная модель/эффорт НЕ должны ронять запуск агента —
|
||||
деградация на default/CLI-дефолт + лог.
|
||||
- **frontmatter автогенерация:** убедиться, что инструмент (если автогенерит
|
||||
frontmatter) не вернёт `model:` обратно. Frontmatter остаётся описательным
|
||||
(`name`/`description`/`tools`).
|
||||
- **enduro per-project override** не должен сломаться валидацией (валидные имена
|
||||
проходят без изменения поведения).
|
||||
|
||||
## 7. Бизнес-эффект
|
||||
|
||||
- Frontmatter перестаёт лгать → меньше риск «починки», ломающей агентов.
|
||||
- Опечатка в имени модели больше не роняет/деградирует запуск агента.
|
||||
- (опц.) fallback повышает доступность при перегрузке основной модели.
|
||||
112
docs/work-items/ORCH-074/02-trz.md
Normal file
112
docs/work-items/ORCH-074/02-trz.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# ТЗ — ORCH-074: убрать мёртвый frontmatter `model:` + валидация имени модели
|
||||
|
||||
Work Item ID: ORCH-074
|
||||
Базируется на: BRD `01-brd.md`. Скоп фиксирован решением Славы (08.06):
|
||||
**G1 + G2 + опц. G4. G3 (routing) НЕ включаем. Эффорт НЕ трогать.**
|
||||
|
||||
## 1. Задействованные модули `src/` и файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `.openclaw/agents/analyst.md` | **G1:** удалить строку `model: claude-sonnet-4-6` из frontmatter |
|
||||
| `.openclaw/agents/architect.md` | **G1:** удалить строку `model: claude-opus-4-7` |
|
||||
| `.openclaw/agents/developer.md` | **G1:** удалить строку `model: claude-sonnet-4-6` |
|
||||
| `.openclaw/agents/reviewer.md` | **G1:** удалить строку `model: claude-opus-4-7` |
|
||||
| `.openclaw/agents/tester.md` | **G1:** удалить строку `model: claude-sonnet-4-6` |
|
||||
| `.openclaw/agents/deployer.md` | **G1:** удалить строку `model: claude-sonnet-4-6` |
|
||||
| `src/agents/launcher.py` | **G2:** добавить валидацию имени модели в `resolve_agent_model` (или helper), по образцу `VALID_EFFORTS`-гарда в `resolve_agent_effort` |
|
||||
| `src/config.py` | **G4 (опц.):** задать `agent_fallback_model` (если архитектор решит). При G2 — возможно добавить константу/настройку валидного формата модели |
|
||||
| `docs/architecture/README.md` | **AC-6:** таблица «модель/эффорт по ролям» актуализирована; нет упоминаний sonnet/opus-4-7 как «модели агента» |
|
||||
| `.env.example` | **AC-3/AC-6:** добавить блок `ORCH_AGENT_MODEL_*` / `ORCH_AGENT_EFFORT_*` / `ORCH_AGENT_FALLBACK_MODEL` (сейчас в `.env.example` их НЕТ) |
|
||||
| `CLAUDE.md` | **AC-6:** при необходимости — отметить, что модель агента берётся ТОЛЬКО из config (frontmatter описательный) |
|
||||
| `CHANGELOG.md` | запись о доработке |
|
||||
| `tests/test_resolve_agent_model.py` | **AC-2:** добавить кейсы валидации мусорного имени |
|
||||
|
||||
## 2. G1 — убрать мёртвый frontmatter `model:`
|
||||
|
||||
Удалить **только** строку `model: …` из YAML-frontmatter каждого из 6 файлов
|
||||
`.openclaw/agents/*.md`. Остальные ключи (`name`, `description`, `tools`/`model`-comment)
|
||||
не трогать. frontmatter остаётся валидным YAML и описательным.
|
||||
|
||||
Проверка (AC-1):
|
||||
```
|
||||
grep -L "^model:" .openclaw/agents/*.md # должны вернуться ВСЕ 6 файлов
|
||||
```
|
||||
(`grep -L` печатает файлы БЕЗ совпадения — все 6 не должны содержать `^model:`.)
|
||||
|
||||
## 3. G2 — валидация имени модели (never-break)
|
||||
|
||||
Требование (НЕ предписывает архитектуру — выбор предиката за архитектором):
|
||||
|
||||
- Резолвенное имя модели валидируется ПЕРЕД возвратом из `resolve_agent_model`
|
||||
(либо в общем helper). Невалидное имя → `logger.warning(...)` + откат на
|
||||
следующий валидный уровень (в пределе — `agent_model_default`, а если и он
|
||||
невалиден → `""`, т.е. без флага `--model`, CLI-дефолт). **Никогда** не вернуть
|
||||
мусор, который попадёт в `--model`.
|
||||
- Поведение — точная аналогия `resolve_agent_effort` (`VALID_EFFORTS`): валидный →
|
||||
как есть; невалидный → лог + дроп.
|
||||
- Предикат валидности (на усмотрение архитектора, рекомендация аналитика):
|
||||
формат-чек `claude-*` (forward-compatible — новые версии моделей не требуют
|
||||
правки allowlist) ЛИБО явный `VALID_MODELS` allowlist (строже, но требует
|
||||
поддержки при выходе новых моделей). **Выбор и обоснование — в ADR.**
|
||||
- **Рекомендация аналитика (форма):** оформить предикат как отдельный
|
||||
чистый helper (напр. `is_valid_model(name) -> bool` рядом с `VALID_EFFORTS`),
|
||||
а не инлайнить в `resolve_agent_model` — тогда ОДИН валидатор переиспользуется
|
||||
и резолвом модели, и чтением fallback (G4, см. §4). Финальная форма — за
|
||||
архитектором.
|
||||
- Инвариант обратной совместимости: ВСЕ ныне используемые валидные имена
|
||||
(`claude-opus-4-8`, а также enduro per-project override) проходят валидацию
|
||||
без изменения поведения. Невалидным считается только мусор (опечатка,
|
||||
`gpt-4`, пустая строка после strip и т.п.).
|
||||
- Контракт уровней резолва ORCH-041 сохраняется: валидация добавляется поверх,
|
||||
механизм приоритетов не меняется.
|
||||
|
||||
## 4. G4 — fallback_model (опционально, решает архитектор)
|
||||
|
||||
- `src/config.py::agent_fallback_model` сейчас `""` (флаг не прокидывается).
|
||||
- Если архитектор решит включить — задать каноничное имя модели; launcher уже
|
||||
прокидывает его в `--fallback-model` (`launcher.py:374-375`, попадает в cmd
|
||||
на строке 388).
|
||||
- **⚠️ Код-факт (проверено 08.06):** fallback читается НАПРЯМУЮ —
|
||||
`fb = settings.agent_fallback_model` (`launcher.py:374`) — и **НЕ проходит**
|
||||
через `resolve_agent_model`, значит валидация G2, добавленная внутри
|
||||
`resolve_agent_model`, его НЕ покроет. Следствие для архитектора: если G4
|
||||
включается, валидацию имени модели (G2) надо применить ТАКЖЕ к fallback на
|
||||
его месте чтения (или вынести валидатор в отдельный helper, который вызывают
|
||||
ОБА: и резолв модели, и чтение fallback). Иначе опечатка в `agent_fallback_model`
|
||||
обходит G2 и уезжает в `--fallback-model` — нарушение never-break.
|
||||
- Если архитектор решит НЕ включать — оставить `""`, AC-5 помечается N/A в ADR.
|
||||
|
||||
## 5. Изменения API / схемы БД
|
||||
|
||||
- **API (HTTP):** нет.
|
||||
- **Схема БД:** нет миграций.
|
||||
- **CLI-команда агента:** формируется в `launcher._spawn` (строки 384-392).
|
||||
Меняется только КАЧЕСТВО значения `--model` (валидное/дроп), сама структура
|
||||
команды не меняется.
|
||||
|
||||
## 6. Требования к QG checks
|
||||
|
||||
- Новых QG-чеков НЕ требуется. Валидация — это runtime-гард в launcher, не
|
||||
отдельный quality-gate.
|
||||
|
||||
## 7. Артефакты pipeline
|
||||
|
||||
Должны быть созданы/обновлены в ЭТОМ PR (golden source = код + доки):
|
||||
- `docs/architecture/README.md` — таблица «модель/эффорт по ролям».
|
||||
- `.env.example` — блок переменных моделей/эффорта/fallback.
|
||||
- `CHANGELOG.md` — запись.
|
||||
- `06-adr/ADR-NNN-*.md` — решение по предикату валидации (G2) и по G4 (fallback вкл/выкл).
|
||||
- ADR архитектора фиксирует: выбран вариант G1 «убрать» (не «читать frontmatter»).
|
||||
|
||||
## 8. Эффорт — НЕ ТРОГАТЬ
|
||||
|
||||
`agent_effort_*` корректны (`thinking → high`, `tester/deployer → medium`).
|
||||
Менять только при явном отдельном обосновании (вне скоупа этой задачи).
|
||||
|
||||
## 9. Грабли
|
||||
|
||||
- Имена моделей — каноничные строки Claude CLI; сверить с тем, что реально
|
||||
принимает CLI на проде (`ORCH_CLAUDE_BIN`). НЕ хардкодить версию вне `config.py`.
|
||||
- Если frontmatter автогенерится инструментом — убедиться, что `model:` не вернётся.
|
||||
- Self-hosting: НЕ ронять прод-контейнер; деплой через «Confirm Deploy».
|
||||
81
docs/work-items/ORCH-074/03-acceptance-criteria.md
Normal file
81
docs/work-items/ORCH-074/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Критерии приёмки — ORCH-074
|
||||
|
||||
Work Item ID: ORCH-074
|
||||
Скоп (Слава 08.06): G1 + G2 + опц. G4. **G3 routing снят — AC-4 не применяется.**
|
||||
|
||||
Каждый критерий: чёткое условие PASS/FAIL.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — frontmatter `model:` убран из всех 6 промптов (G1)
|
||||
|
||||
- **PASS:** ни один файл `.openclaw/agents/*.md` не содержит строки `^model:` в
|
||||
frontmatter. Команда `grep -L "^model:" .openclaw/agents/*.md` возвращает все 6
|
||||
файлов (analyst, architect, developer, reviewer, tester, deployer).
|
||||
- **FAIL:** хотя бы в одном файле осталась строка `model:`.
|
||||
- Доп. инвариант: frontmatter остаётся валидным YAML; ключи `name`/`description`/`tools`
|
||||
сохранены.
|
||||
|
||||
## AC-2 — валидация имени модели, never-break (G2)
|
||||
|
||||
- **PASS:** при невалидном `agent_model_*` / project-override (мусорное имя)
|
||||
`resolve_agent_model` возвращает откат на default (или `""`), пишет
|
||||
`logger.warning`, и мусор **никогда** не попадает в `--model`. Покрыто
|
||||
unit-тестом с мусорным именем (см. `04-test-plan.yaml`, TC-03..TC-05).
|
||||
- **FAIL:** мусорное имя проходит насквозь в `--model`, или валидация роняет
|
||||
запуск агента (исключение вместо graceful-деградации).
|
||||
|
||||
## AC-3 — resolve_agent_model осмыслен для всех 6 агентов
|
||||
|
||||
- **PASS:** для каждого из 6 агентов `resolve_agent_model(agent)` (без
|
||||
project_id) возвращает `claude-opus-4-8` (routing G3 выключен → intelligence-
|
||||
модель для всех). Значение документировано в README (таблица env) и `.env.example`.
|
||||
- **FAIL:** хотя бы один агент резолвится в пустую/невалидную/устаревшую модель,
|
||||
либо документация не отражает фактическую модель.
|
||||
|
||||
## AC-4 — routing (G3) — **СНЯТ (N/A)**
|
||||
|
||||
- Routing НЕ включается в этой задаче. Критерий не применяется. ADR фиксирует
|
||||
отказ от G3 как осознанное решение Славы (08.06).
|
||||
|
||||
## AC-5 — fallback_model (G4, опционально)
|
||||
|
||||
- **PASS (если G4 включён):** `agent_fallback_model` задан каноничным именем,
|
||||
проходит валидацию G2, прокидывается в `--fallback-model` (launcher 374-375).
|
||||
Доп. инвариант never-break: МУСОРНЫЙ fallback НЕ попадает в `--fallback-model`
|
||||
(валидируется тем же предикатом G2; учтено, что fallback читается напрямую на
|
||||
`launcher.py:374`, минуя `resolve_agent_model` — см. TRZ §4). Задокументирован.
|
||||
- **PASS (если G4 НЕ включён):** `agent_fallback_model = ""`, ADR явно фиксирует
|
||||
отказ; AC-5 помечен N/A.
|
||||
- **FAIL:** fallback задан невалидным именем, ИЛИ невалидный fallback проходит в
|
||||
`--fallback-model`, ИЛИ включён без документации/ADR.
|
||||
|
||||
## AC-6 — синхронизация документации
|
||||
|
||||
- **PASS:** `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`
|
||||
синхронизированы — таблица «модель по ролям» актуальна (все = `claude-opus-4-8`);
|
||||
НЕТ упоминаний `claude-sonnet-4-6` / `claude-opus-4-7` как «модели агента»
|
||||
(если они не используются). `.env.example` содержит блок
|
||||
`ORCH_AGENT_MODEL_*` / `ORCH_AGENT_EFFORT_*` / `ORCH_AGENT_FALLBACK_MODEL`.
|
||||
- **FAIL:** документация противоречит config, или остались мёртвые упоминания
|
||||
sonnet/opus-4-7 как модели агента.
|
||||
|
||||
## AC-7 — pytest зелёный + never-break
|
||||
|
||||
- **PASS:** `pytest tests/ -q` зелёный. Невалидная модель/эффорт НЕ роняет запуск
|
||||
агента (graceful-деградация подтверждена тестами).
|
||||
- **FAIL:** падают тесты, или невалидный вход роняет запуск.
|
||||
|
||||
## AC-8 — enduro per-project override не сломан
|
||||
|
||||
- **PASS:** валидный per-project override (`projects.py agent_models`) для не-self
|
||||
проекта (enduro) резолвится и проходит валидацию без изменения поведения
|
||||
(покрыто существующими тестами `test_resolve_agent_model.py`).
|
||||
- **FAIL:** валидация ломает корректный per-project override.
|
||||
|
||||
## AC-9 — ADR зафиксирован
|
||||
|
||||
- **PASS:** ADR в `06-adr/` фиксирует: (а) выбран вариант G1 «убрать frontmatter»
|
||||
(не «читать»); (б) предикат валидации G2 (формат-чек vs allowlist) с обоснованием;
|
||||
(в) решение по G4 (вкл/выкл) и по отказу от G3.
|
||||
- **FAIL:** ADR отсутствует или не покрывает эти решения.
|
||||
103
docs/work-items/ORCH-074/04-test-plan.yaml
Normal file
103
docs/work-items/ORCH-074/04-test-plan.yaml
Normal file
@@ -0,0 +1,103 @@
|
||||
work_item: ORCH-074
|
||||
# Скоп (Слава 08.06): G1 + G2 + опц. G4. G3 routing снят (no routing tests).
|
||||
# Эффорт не трогаем (no new effort tests beyond never-break regression).
|
||||
|
||||
tests:
|
||||
# ---- G1: frontmatter `model:` убран из всех 6 промптов (AC-1) ----
|
||||
- id: TC-01
|
||||
type: integration
|
||||
description: >
|
||||
Ни один .openclaw/agents/*.md не содержит строки `^model:` во frontmatter.
|
||||
Тест итерирует по 6 файлам, ассертит отсутствие model:-строки.
|
||||
module: tests/test_agent_frontmatter_no_model.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: integration
|
||||
description: >
|
||||
frontmatter каждого из 6 промптов остаётся валидным YAML и сохраняет ключи
|
||||
name/description (парсинг между первыми двумя '---' без ошибок).
|
||||
module: tests/test_agent_frontmatter_no_model.py
|
||||
expected: PASS
|
||||
|
||||
# ---- G2: валидация имени модели, never-break (AC-2, AC-7) ----
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
Мусорное имя в agent_model_<agent> (напр. 'gpt-4' или 'claud-opus-typo')
|
||||
-> resolve_agent_model откатывается на default (claude-opus-4-8) и НЕ
|
||||
возвращает мусор. Проверяется также warning в логах (caplog).
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
Мусорное имя в project-override (agent_models) -> resolve_agent_model
|
||||
откатывается на следующий валидный уровень (default), мусор не передаётся.
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
Невалиден И override, И default -> resolve_agent_model возвращает ""
|
||||
(без флага --model, CLI-дефолт). never-break: исключение НЕ бросается.
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: >
|
||||
Валидное каноничное имя (claude-opus-4-8) проходит валидацию без изменения:
|
||||
resolve_agent_model('developer') == 'claude-opus-4-8'. Регрессия ORCH-041.
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
# ---- AC-3: все 6 агентов резолвятся в осмысленную модель ----
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: >
|
||||
Для всех 6 агентов (analyst/architect/developer/reviewer/tester/deployer)
|
||||
resolve_agent_model(agent) == 'claude-opus-4-8' (routing выключен).
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
# ---- AC-8: enduro per-project override не сломан валидацией ----
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: >
|
||||
Валидный per-project override (agent_models у не-self проекта) резолвится и
|
||||
проходит валидацию без изменения поведения (регрессия ORCH-041).
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
# ---- G4: fallback_model (опц.) — условный тест ----
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: >
|
||||
ЕСЛИ G4 включён архитектором: agent_fallback_model задан валидным именем и
|
||||
проходит валидацию G2. ЕСЛИ выключен: agent_fallback_model == "" (тест
|
||||
подтверждает дефолт). Финальная форма теста зависит от решения в ADR.
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
# ---- G4 never-break: fallback читается напрямую (launcher.py:374), мимо
|
||||
# resolve_agent_model — валидация G2 должна покрыть и его (см. TRZ §4) ----
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: >
|
||||
ЕСЛИ G4 включён: мусорное agent_fallback_model НЕ попадает в --fallback-model
|
||||
(валидируется тем же предикатом G2, дропается с warning, never-break).
|
||||
ЕСЛИ G4 выключен: кейс помечается N/A в test-report (синхронно с ADR).
|
||||
module: tests/test_resolve_agent_model.py
|
||||
expected: PASS
|
||||
|
||||
# ---- AC-7: общий зелёный прогон / never-break regression ----
|
||||
- id: TC-10
|
||||
type: integration
|
||||
description: >
|
||||
Полный pytest зелёный; невалидная модель/эффорт не роняет запуск агента
|
||||
(graceful-деградация). Регрессия resolve_agent_effort (VALID_EFFORTS) цела.
|
||||
module: tests/
|
||||
expected: PASS
|
||||
145
docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md
Normal file
145
docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# ADR-001: Убрать мёртвый frontmatter `model:` + валидация имени модели через формат-чек `claude-*`
|
||||
|
||||
Work Item ID: ORCH-074
|
||||
Эпик: ORCH-052 (слой 3), под-задача ORCH-52a
|
||||
Связан с: ORCH-041 (каркас `resolve_agent_model`/`resolve_agent_effort`), `src/config.py`, `src/agents/launcher.py`
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Каркас выбора модели агентов (ORCH-041) работает корректно: `launcher.resolve_agent_model(agent, project_id)`
|
||||
резолвит модель по приоритету project-override → `ORCH_AGENT_MODEL_<AGENT>` → `agent_model_default`
|
||||
→ CLI-дефолт. Все 6 агентов резолвятся в `claude-opus-4-8` (через `agent_model_default`).
|
||||
|
||||
Аудит кода (08.06) выявил два дефекта **данных/валидации** (не дефект механизма):
|
||||
|
||||
- **P1 — лживый/мёртвый `model:` во frontmatter.** Все 6 промптов `.openclaw/agents/*.md`
|
||||
содержат `model:` (`claude-sonnet-4-6` у analyst/developer/tester/deployer, `claude-opus-4-7`
|
||||
у architect/reviewer). launcher **не читает** frontmatter `model:` — это мёртвая декларация,
|
||||
которая лжёт о реально используемой модели и нарушает принцип «документация = golden source».
|
||||
Мина: если кто-то «починит» launcher читать frontmatter → все агенты молча уедут на устаревшие
|
||||
модели.
|
||||
- **P2 — нет валидации имени модели.** В отличие от effort (`VALID_EFFORTS`-гард в
|
||||
`resolve_agent_effort`), имя модели не валидируется. Опечатка в `agent_model_*` / project-override
|
||||
→ `--model <мусор>` → CLI падает или тихо деградирует. Нарушение принципа never-break.
|
||||
|
||||
Скоуп зафиксирован стейкхолдером (Слава, 08.06): **G1 + G2 + опц. G4. G3 routing НЕ включаем
|
||||
(все 6 агентов остаются `claude-opus-4-8`). Эффорт не трогаем.** rev.2 BRD подтвердила скоуп
|
||||
без изменений. Код-факт (TRZ §4): `agent_fallback_model` читается напрямую на `launcher.py:374`,
|
||||
минуя `resolve_agent_model`.
|
||||
|
||||
Архитектор должен зафиксировать три решения: (а) форма G1, (б) предикат валидации G2,
|
||||
(в) судьба G4 (fallback) и G3 (routing).
|
||||
|
||||
## Решение
|
||||
|
||||
### Решение 1 (G1): убрать `model:` из frontmatter, НЕ учить launcher его читать
|
||||
|
||||
Из YAML-frontmatter всех 6 файлов `.openclaw/agents/*.md` удаляется **только** строка `model: …`.
|
||||
Ключи `name`/`description`/`tools` сохраняются; frontmatter остаётся валидным YAML и **описательным**.
|
||||
config (`agent_model_*` / `agent_model_default`) остаётся **единственным источником правды** о модели.
|
||||
|
||||
Отвергнутая альтернатива — научить launcher читать frontmatter `model:` — отвергнута: она вводит
|
||||
второй источник правды (frontmatter ⊕ config), усложняет резолв, и моментально активировала бы
|
||||
устаревшие значения (sonnet-4-6 / opus-4-7) для всех агентов. «Убрать» проще, безопаснее и
|
||||
устраняет мину раз и навсегда.
|
||||
|
||||
### Решение 2 (G2): предикат валидации — формат-чек `claude-*`, оформленный отдельным helper
|
||||
|
||||
Добавляется **чистый helper** `is_valid_model(name: str) -> bool` рядом с `VALID_EFFORTS` в
|
||||
`src/agents/launcher.py`. Предикат — **формат-чек**, а не allowlist имён:
|
||||
|
||||
```
|
||||
strip → непустая строка → соответствует ^claude-[a-z0-9.-]+$
|
||||
```
|
||||
|
||||
То есть: имя после `strip()` непусто, начинается с `claude-` и состоит только из строчных
|
||||
букв/цифр/точек/дефисов. Регэксп оформляется модульной константой (напр. `_MODEL_NAME_RE`).
|
||||
|
||||
**Почему формат-чек, а не allowlist `VALID_MODELS`:**
|
||||
allowlist (по образцу `VALID_EFFORTS`) воссоздаёт ровно ту мину, которую мы убиваем в G1 — статичный
|
||||
список имён, который **врёт при устаревании**. Когда Anthropic выпустит `claude-opus-4-9`, оператор,
|
||||
корректно прописавший новую модель, получит её молчаливый дроп на устаревший default (never-break
|
||||
сработает против пользователя). Это хуже, чем пропустить структурно-корректное, но опечатанное имя:
|
||||
финальный авторитет о существовании модели — сам Claude CLI, а не наш код. Формат-чек
|
||||
**forward-compatible** (новые версии проходят без правки кода) и ловит реальные классы отказов:
|
||||
чужой провайдер (`gpt-4`), пустая строка/пробелы, мусор с недопустимыми символами, неверный префикс
|
||||
(`claud-opus-typo`). Признанное ограничение: формат-чек НЕ ловит опечатку, которая всё ещё выглядит
|
||||
как валидное claude-имя (`claude-opus-typo`) — такие отсекает CLI на запуске (контракт never-break
|
||||
+ exit-code обработка в `_monitor_agent` это покрывают). Задача валидатора — не быть реестром моделей,
|
||||
а не дать **структурному мусору** уехать в `--model`.
|
||||
|
||||
**Применение (контракт never-break):**
|
||||
- В `resolve_agent_model`: резолвенное имя валидируется **перед возвратом**. Невалидное →
|
||||
`logger.warning(...)` + откат на следующий валидный уровень. Реализация: helper применяется внутри
|
||||
каскада приоритетов так, что невалидный уровень пропускается (project-override невалиден → пробуем
|
||||
env → default), а если итог всё равно невалиден → возврат `""` (без флага `--model`, CLI-дефолт).
|
||||
**Никогда** не возвращается мусор и **никогда** не бросается исключение.
|
||||
- Контракт уровней резолва ORCH-041 сохраняется: валидация добавляется **поверх**, порядок приоритетов
|
||||
и сигнатуры не меняются. Все ныне используемые валидные имена (`claude-opus-4-8`, валидный enduro
|
||||
per-project override) проходят без изменения поведения.
|
||||
- Поведенческая аналогия с `resolve_agent_effort` (`VALID_EFFORTS`): валидный → как есть, невалидный →
|
||||
лог + дроп. Разница только в форме предиката (формат-чек vs множество) по причинам выше.
|
||||
|
||||
### Решение 3 (G4): fallback НЕ включаем; но валидатор применяем к точке чтения fallback
|
||||
|
||||
`agent_fallback_model` остаётся `""` (флаг `--fallback-model` не прокидывается). **AC-5 помечается
|
||||
N/A.** Обоснование отказа:
|
||||
- G3 выключен ради **детерминизма**: все агенты на `claude-opus-4-8`. Fallback вернул бы скрытую
|
||||
вариативность модели под нагрузкой (агент молча отработал бы на другой модели) — это противоречит
|
||||
духу зафиксированного скоупа.
|
||||
- Нет наблюдаемой проблемы доступности, мотивирующей fallback. Принцип минимального изменения.
|
||||
- Self-hosting: новое рантайм-поведение под нагрузкой трудно наблюдать; не вводим без нужды.
|
||||
|
||||
**При этом** helper `is_valid_model` применяется ТАКЖЕ на месте чтения fallback (`launcher.py:374`,
|
||||
`fb = settings.agent_fallback_model`) — **независимо** от того, что значение сейчас пустое. Причина —
|
||||
код-факт TRZ §4: fallback читается напрямую, мимо `resolve_agent_model`, поэтому валидация только
|
||||
внутри резолва его НЕ покрывает. Защитный гард на месте чтения навсегда закрывает дыру never-break:
|
||||
если кто-то позже задаст `ORCH_AGENT_FALLBACK_MODEL` с опечаткой, мусор будет залогирован и
|
||||
сброшен (`fb_flag = ""`), а не уедет в `--fallback-model`. Для текущего пустого значения регрессии нет:
|
||||
`is_valid_model("") == False` → `fb_flag = ""` — то же поведение, что и сейчас (`if fb`). Это делает
|
||||
**TC-11** проверяемым (мусорный fallback дропается) при выключенном G4.
|
||||
|
||||
### Решение 4 (G3): routing НЕ включаем
|
||||
|
||||
Подтверждается отказ от model-routing как осознанное решение стейкхолдера (Слава, 08.06). Все 6
|
||||
агентов резолвятся в `claude-opus-4-8`. **AC-4 = N/A.**
|
||||
|
||||
## Размещение и форма (для разработчика)
|
||||
|
||||
- `is_valid_model(name)` + `_MODEL_NAME_RE` — в `src/agents/launcher.py` рядом с `VALID_EFFORTS`
|
||||
(один валидатор, два места вызова: резолв модели и чтение fallback — оба в этом модуле, без
|
||||
кросс-модульного импорта).
|
||||
- Префикс `claude-` хардкодится в launcher: оркестратор привязан к Claude CLI (`CLAUDE_BIN`),
|
||||
конфигурировать предикат не нужно (не over-engineering). Каноничная версия модели по-прежнему
|
||||
живёт ТОЛЬКО в `config.py::agent_model_default` — в launcher версия не хардкодится.
|
||||
- frontmatter: удалить только `model:`-строку; не вносить генератор, возвращающий её обратно.
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- frontmatter перестаёт лгать; config — единственный источник правды о модели (golden source цел).
|
||||
- Опечатка/чужой провайдер/мусор в имени модели больше не роняет и не деградирует запуск агента
|
||||
(never-break соблюдён в обеих точках: резолв и fallback).
|
||||
- Forward-compatible: будущие модели Claude не требуют правки кода (в отличие от allowlist).
|
||||
- Минимальное изменение: механизм ORCH-041, API, схема БД, структура CLI-команды не меняются.
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- Формат-чек пропускает структурно-валидную опечатку вида `claude-opus-typo` (отсекается CLI на
|
||||
запуске + never-break обработкой exit-code). Принятый компромисс ради forward-compat.
|
||||
- Префикс `claude-` зашит — при гипотетической смене CLI-провайдера потребуется правка (приемлемо:
|
||||
оркестратор Claude-специфичен по дизайну).
|
||||
|
||||
**Не затрагивается:**
|
||||
- API (HTTP) — нет. Схема БД — нет миграций. Стадии/QG — без изменений (это runtime-гард в launcher,
|
||||
не quality-gate). Топология/инфра — без изменений (07/08 артефакты не требуются).
|
||||
- Эффорт (`agent_effort_*`) и `VALID_EFFORTS`-гард — не трогаются (регрессия покрыта TC-10).
|
||||
- enduro per-project override — валидные имена проходят без изменения поведения (AC-8 / TC-08).
|
||||
|
||||
## Соответствие принципам
|
||||
|
||||
Всё в Docker / один сервер — да. Минимум зависимостей — новых нет. Без ORM/очередей/облака — да.
|
||||
Self-hosting: изменение применяется к БУДУЩИМ запускам агентов, прод-контейнер не перезапускается
|
||||
в рамках задачи; прод-деплой орка — только через staging-гейт (8501) и Plane-статус «Confirm Deploy».
|
||||
23
docs/work-items/ORCH-074/10-tech-risks.md
Normal file
23
docs/work-items/ORCH-074/10-tech-risks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Технические риски — ORCH-074
|
||||
|
||||
Work Item ID: ORCH-074
|
||||
Связан с: ADR-001 (`06-adr/ADR-001-model-name-validation.md`).
|
||||
|
||||
| ID | Риск | Вероятность | Влияние | Митигация |
|
||||
|----|------|-------------|---------|-----------|
|
||||
| R-1 | **Валидация роняет запуск агента** (исключение вместо graceful-деградации) — нарушение never-break, встал бы конвейер всех проектов. | Низкая | Высокое | Helper `is_valid_model` — чистый предикат без исключений; невалидное → `logger.warning` + откат на default/`""`. Покрыто TC-03..TC-05, TC-10. |
|
||||
| R-2 | **Fallback обходит валидацию** (код-факт: `launcher.py:374` читает `agent_fallback_model` напрямую, мимо `resolve_agent_model`). | Средняя (если позже зададут fallback) | Среднее | ADR-001 решение 3: один helper применяется ТАКЖЕ на месте чтения fallback. Мусорный fallback дропается с warning. Покрыто TC-11. |
|
||||
| R-3 | **Регрессия enduro per-project override** — валидация ломает корректный не-self override (общий инстанс/БД/очередь). | Низкая | Высокое | Валидные claude-имена проходят формат-чек без изменения поведения; механизм приоритетов ORCH-041 не меняется. Покрыто TC-08. |
|
||||
| R-4 | **Формат-чек пропускает структурную опечатку** вида `claude-opus-typo` (валидный префикс, несуществующая модель). | Средняя | Низкое | Принятый компромисс (ADR-001): финальный авторитет — CLI; never-break + обработка exit-code в `_monitor_agent` покрывают отказ запуска. Allowlist отвергнут как воссоздающий мину устаревания (G1). |
|
||||
| R-5 | **frontmatter-генератор возвращает `model:` обратно** → мина P1 оживает. | Низкая | Среднее | Проверить отсутствие автогенератора, возвращающего `model:`; frontmatter остаётся описательным. Покрыто TC-01/TC-02 (CI-гард на отсутствие `^model:`). |
|
||||
| R-6 | **Хардкод версии модели в launcher** при добавлении валидации. | Низкая | Среднее | Префикс `claude-` зашит осознанно (CLI-специфика); каноничная ВЕРСИЯ остаётся только в `config.py::agent_model_default`. Регэксп версию не фиксирует. |
|
||||
| R-7 | **Self-hosting деплой** — рестарт прод-контейнера встанет конвейер всех проектов (enduro). | — | Высокое | Изменение применяется к будущим запускам; прод-деплой только через staging-гейт (8501) и Plane-статус «Confirm Deploy». Без немедленного рестарта прода. |
|
||||
|
||||
## Инварианты (должны держаться после изменения)
|
||||
|
||||
1. **never-break**: невалидная модель/эффорт/fallback НЕ роняет запуск агента — деградация на
|
||||
default/CLI-дефолт + лог.
|
||||
2. **Один источник правды о модели**: config (`agent_model_*`); frontmatter — описательный.
|
||||
3. **Обратная совместимость ORCH-041**: все валидные имена (`claude-opus-4-8`, enduro override)
|
||||
резолвятся без изменения поведения; порядок приоритетов и сигнатуры не меняются.
|
||||
4. **Детерминизм**: все 6 агентов = `claude-opus-4-8` (G3/routing выключен, G4/fallback выключен).
|
||||
69
docs/work-items/ORCH-074/12-review.md
Normal file
69
docs/work-items/ORCH-074/12-review.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-074
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-074
|
||||
|
||||
## Summary
|
||||
PR закрывает оба зафиксированных дефекта каркаса выбора модели (ORCH-41) в рамках
|
||||
скоупа G1 + G2 (+ защитный гард точки чтения fallback при выключенном G4), без
|
||||
изменения механизма резолва, API или схемы БД. Реализация точно соответствует
|
||||
ADR-001 и ТЗ; документация синхронизирована в том же PR; все 1012 тестов зелёные.
|
||||
Вердикт — **APPROVED**, P0/P1 findings нет.
|
||||
|
||||
## Соответствие ТЗ и AC
|
||||
- **AC-1 (G1):** `grep -L "^model:" .openclaw/agents/*.md` возвращает все 6 файлов;
|
||||
ни одной строки `^model:` не осталось. frontmatter остаётся валидным YAML
|
||||
(`name`/`description`/`tools` сохранены) — покрыто `test_agent_frontmatter_no_model.py`.
|
||||
- **AC-2 (G2 never-break):** `resolve_agent_model` валидирует имя через `is_valid_model`
|
||||
ПЕРЕД возвратом, мусорный уровень логируется (`logger.warning`) и пропускается;
|
||||
при невалидных всех уровнях → `""` (CLI-дефолт), исключение не бросается. TC-03..05.
|
||||
- **AC-3:** все 6 агентов резолвятся в `claude-opus-4-8` (TC-07), значение в README-таблице
|
||||
и `.env.example`.
|
||||
- **AC-4 (G3):** N/A — отказ зафиксирован в ADR.
|
||||
- **AC-5 (G4):** `agent_fallback_model=""` (выкл); тот же предикат гардит inline-чтение
|
||||
fallback в `_spawn` (код-факт TRZ §4 учтён) — мусорный fallback дропается. ADR помечает N/A.
|
||||
- **AC-6 (доки):** README (новая секция «Модель и эффорт по ролям» + валидация),
|
||||
`CLAUDE.md`, `.env.example` синхронизированы; стале-упоминаний `claude-sonnet-4-6`/
|
||||
`claude-opus-4-7` как модели агента в актуальных доках нет (`grep` пуст).
|
||||
- **AC-7:** `pytest tests/ -q` → 1012 passed.
|
||||
- **AC-8:** валидный enduro per-project override проходит без изменения поведения (TC-08).
|
||||
- **AC-9:** ADR-001 фиксирует G1 «убрать», предикат G2 (формат-чек vs allowlist с
|
||||
обоснованием), решения по G4 и G3.
|
||||
|
||||
## Соответствие ADR
|
||||
Реализация 1:1 с ADR-001: `is_valid_model` + `_MODEL_NAME_RE` (`^claude-[a-z0-9.-]+$`)
|
||||
рядом с `VALID_EFFORTS`; один предикат, две точки вызова (резолв модели и чтение
|
||||
fallback); каскад приоритетов ORCH-41 сохранён (рефакторинг на генератор
|
||||
`_agent_model_candidates` с валидацией-со-скипом); версия модели по-прежнему живёт
|
||||
только в `config.py::agent_model_default`. Глобальные ADR не нарушены.
|
||||
|
||||
## Качество кода
|
||||
- `is_valid_model` корректно обрабатывает `None`/пустое/whitespace (`if not name`),
|
||||
никогда не бросает; содержательные docstrings с обоснованием формат-чека.
|
||||
- never-break соблюдён в обеих точках; `if fb` short-circuit сохраняет нулевую
|
||||
регрессию для текущего пустого fallback.
|
||||
- Тесты содержательные: предикат (accept/reject), каскад-скип, граничные кейсы,
|
||||
регрессия per-project override, выключенный G4.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
## Документация
|
||||
Обновлена полностью в этом же PR: `docs/architecture/README.md` (компонент Agent
|
||||
Launcher + новая секция «Модель и эффорт по ролям» с таблицей и описанием валидации),
|
||||
`CLAUDE.md` (строка про источник модели и валидацию), `.env.example` (блок
|
||||
`ORCH_AGENT_MODEL_*`/`ORCH_AGENT_EFFORT_*`/`ORCH_AGENT_FALLBACK_MODEL`),
|
||||
`CHANGELOG.md` (запись по задаче), ADR `06-adr/ADR-001-model-name-validation.md`.
|
||||
Требование «изменён src/ → обновлена документация» выполнено.
|
||||
82
docs/work-items/ORCH-074/13-test-report.md
Normal file
82
docs/work-items/ORCH-074/13-test-report.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-074
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-074
|
||||
|
||||
Убрать мёртвый frontmatter `model:` из 6 промптов + валидация имени модели (never-break).
|
||||
Скоп: G1 + G2 + опц. G4 (выключен). G3 routing снят. Review-вердикт: APPROVED.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-074-orch-52a-frontmatter-routing-e (worktree)
|
||||
- prod health (8500): `{"status":"ok","service":"orchestrator"}`
|
||||
- Дата: 2026-06-08
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-01 | Ни один `.openclaw/agents/*.md` не содержит `^model:` (G1, AC-1) | test_no_model_line_in_frontmatter[×6] | PASS |
|
||||
| TC-02 | frontmatter валидный YAML, ключи name/description сохранены | test_frontmatter_still_valid_yaml_with_keys[×6] | PASS |
|
||||
| TC-03 | Мусорный `agent_model_<agent>` → откат на default, warning, мусор не в `--model` | test_garbage_per_agent_env_falls_back_to_default | PASS |
|
||||
| TC-04 | Мусорный project-override → откат на default | test_garbage_project_override_falls_back_to_default | PASS |
|
||||
| TC-05 | Невалидны override И default → `""` (CLI-дефолт), без исключения | test_all_levels_invalid_returns_empty | PASS |
|
||||
| TC-06 | Валидное `claude-opus-4-8` проходит без изменения (регрессия ORCH-041) | test_valid_canonical_unchanged | PASS |
|
||||
| TC-07 | Все 6 агентов резолвятся в `claude-opus-4-8` (routing выкл) | test_all_six_agents_resolve_to_opus_4_8 | PASS |
|
||||
| TC-08 | Валидный enduro per-project override не сломан валидацией | test_valid_per_project_override_unchanged | PASS |
|
||||
| TC-09 | G4 выключен: `agent_fallback_model == ""` (дефолт) | test_fallback_model_disabled_by_default | PASS |
|
||||
| TC-10 | Полный pytest зелёный; never-break graceful-деградация | tests/ (1012 passed) | PASS |
|
||||
| TC-11 | G4 never-break (мусорный fallback не в `--fallback-model`) | — | N/A (G4 выключен, синхр. с ADR/AC-5) |
|
||||
|
||||
Доп. предикат-юниты: `test_is_valid_model_accepts_canonical`, `test_is_valid_model_rejects_garbage` — PASS.
|
||||
|
||||
## Проверка критериев приёмки
|
||||
|
||||
| AC | Статус | Подтверждение |
|
||||
|----|--------|---------------|
|
||||
| AC-1 frontmatter `model:` убран | PASS | `grep -L "^model:" .openclaw/agents/*.md` → все 6 файлов; `grep -rn "^model:"` → пусто |
|
||||
| AC-2 валидация never-break | PASS | TC-03..05 |
|
||||
| AC-3 все 6 → `claude-opus-4-8` | PASS | TC-07 |
|
||||
| AC-4 routing G3 | N/A | снят решением (ADR) |
|
||||
| AC-5 fallback G4 | PASS | G4 выключен, `agent_fallback_model=""`, ADR фиксирует отказ (TC-09) |
|
||||
| AC-6 синхронизация доков | PASS | проверено reviewer (README/CLAUDE.md/.env.example) |
|
||||
| AC-7 pytest зелёный | PASS | 1012 passed |
|
||||
| AC-8 enduro override | PASS | TC-08 |
|
||||
| AC-9 ADR | PASS | 06-adr/ADR-001 присутствует |
|
||||
|
||||
## Smoke test API (prod, read-only)
|
||||
```
|
||||
GET /health → HTTP 200 {"status":"ok","service":"orchestrator"}
|
||||
GET /status → HTTP 200
|
||||
GET /queue → HTTP 200
|
||||
```
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
$ python -m pytest tests/ -q
|
||||
1012 passed, 1 warning in 22.07s
|
||||
|
||||
$ python -m pytest tests/test_agent_frontmatter_no_model.py tests/test_resolve_agent_model.py -v
|
||||
32 passed, 1 warning in 0.37s
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в `src/config.py:5`, существующий, вне скоупа задачи.)
|
||||
|
||||
## AC-1 grep-проверка
|
||||
```
|
||||
$ grep -L "^model:" .openclaw/agents/*.md
|
||||
.openclaw/agents/analyst.md
|
||||
.openclaw/agents/architect.md
|
||||
.openclaw/agents/deployer.md
|
||||
.openclaw/agents/developer.md
|
||||
.openclaw/agents/reviewer.md
|
||||
.openclaw/agents/tester.md
|
||||
$ grep -rn "^model:" .openclaw/agents/*.md # пусто (exit 1)
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS** — все применимые тест-кейсы (TC-01..10) зелёные, TC-11 корректно N/A (G4 выключен),
|
||||
все AC выполнены (AC-4 — N/A по скоупу), smoke API OK. Задача готова к стадии deploy-staging.
|
||||
12
docs/work-items/ORCH-074/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-074/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-074
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
56
docs/work-items/ORCH-074/15-staging-log.md
Normal file
56
docs/work-items/ORCH-074/15-staging-log.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T18:57:59+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed inside the `orchestrator-staging` container
|
||||
(`docker exec` via Docker Engine API, ADR-001 / ORCH-048 canonical method —
|
||||
preserves the running instance's process-env so the B6 registry-isolation check
|
||||
reads `.env.staging` correctly).
|
||||
|
||||
- Command: `python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
|
||||
- Exit code: **0** → `staging_status: SUCCESS`
|
||||
- Result: **8/10 checks PASS**, REAL failed: none.
|
||||
|
||||
## Infra waiver (ORCH-061)
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
C9a/C9b are the two sandbox-infra-only checks (depend on SANDBOX bot accounts being
|
||||
project members, not on the pipeline). Both were tolerated because every REAL check
|
||||
is green; the script still exits 0 (fail-closed for any real failure). Trusting the
|
||||
exit code per ORCH-061 — no re-judging of waived checks.
|
||||
|
||||
## Full output
|
||||
|
||||
```
|
||||
[Block A] SMOKE
|
||||
✓ PASS A1 GET /health → 200 status=ok [HTTP 200, body={'status': 'ok', 'service': 'orchestrator'}]
|
||||
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience
|
||||
✓ PASS A3 ORCH_STAGING=true (not prod) [ORCH_STAGING=true]
|
||||
|
||||
[Block B] ACCESS
|
||||
✓ PASS B4 Plane: sandbox project accessible [HTTP 200, found 5 project(s), sandbox=YES]
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true
|
||||
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
|
||||
|
||||
[Block C] E2E (mode=stub)
|
||||
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201]
|
||||
✓ PASS C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
|
||||
✗ FAIL C9a Branch appears in orchestrator-sandbox [branch=not found] (SANDBOX_INFRA, waived)
|
||||
✗ FAIL C9b Analyst job enqueued in staging queue (SANDBOX_INFRA, waived)
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted Plane issue (HTTP 204)
|
||||
|
||||
RESULT: 8/10 checks PASS
|
||||
REAL failed : none
|
||||
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
|
||||
tolerance: staging_infra_tolerance_enabled=True
|
||||
```
|
||||
7
docs/work-items/ORCH-081/00-business-request.md
Normal file
7
docs/work-items/ORCH-081/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-52h: эффорт агентов резолвится в пустую строку в проде (env перебивает config)
|
||||
|
||||
Work Item ID: ORCH-081
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
82
docs/work-items/ORCH-081/01-brd.md
Normal file
82
docs/work-items/ORCH-081/01-brd.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 01 — BRD: ORCH-081 (ORCH-52h)
|
||||
|
||||
**Work Item:** ORCH-081
|
||||
**Эпик:** ORCH-052 (продолжение ORCH-52a / ORCH-074)
|
||||
**Тип:** Багфикс (конфигурация эффорта агентов)
|
||||
**Приоритет:** HIGH
|
||||
**Repo:** orchestrator (self-hosting)
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
При проверке ORCH-074 (08.06) обнаружено: `resolve_agent_effort()` для **всех 6 агентов
|
||||
в проде** возвращает пустую строку `''`, хотя в `src/config.py` заданы осмысленные
|
||||
дефолты (`agent_effort_default="high"`, per-agent `high`/`medium`). Итог: флаг
|
||||
`--effort` **не передаётся** в Claude CLI, и каждый агент бежит на встроенном
|
||||
CLI-дефолте эффорта, а **не** на заявленном `high`/`medium`.
|
||||
|
||||
### Корень (диагностика)
|
||||
В проде env-переменные `ORCH_AGENT_EFFORT_DEFAULT` и
|
||||
`ORCH_AGENT_EFFORT_{ANALYST,ARCHITECT,DEVELOPER,REVIEWER,TESTER,DEPLOYER}` выставлены в
|
||||
**пустую строку** (`VAR=` без значения). Pydantic Settings трактует присутствующую
|
||||
env-переменную (даже пустую) как явное значение и **перебивает** дефолт класса:
|
||||
`agent_effort_* = ''`. В цепочке резолва (`launcher._resolve_agent_attr`):
|
||||
- per-agent `''` → falsy → пропуск (уровень 2);
|
||||
- default `''` → falsy → пропуск (уровень 3);
|
||||
- → возврат `''` (уровень 4, «без флага»).
|
||||
|
||||
Поскольку **и default тоже пуст**, привычный откат «per-agent пуст → взять default»
|
||||
не спасает: откатываться не на что. Это ключевой нюанс — фикс обязан давать каждой
|
||||
роли непустой «пол» (floor) даже когда И per-agent, И default env пусты.
|
||||
|
||||
## 2. Бизнес-ценность / зачем важно
|
||||
|
||||
Для Opus 4.8 (канон Anthropic) уровень reasoning-эффорта влияет на качество вывода
|
||||
**сильнее**, чем у прежних моделей. Coding/agentic роли (особенно `developer`) должны
|
||||
идти минимум на `high`, а `developer` — кандидат на `xhigh`. Сейчас фактически работает
|
||||
неконтролируемый CLI-дефолт → прямой удар по стратегии надёжности и предсказуемости
|
||||
качества всего конвейера (включая enduro-trails из общего инстанса).
|
||||
|
||||
## 3. Решение (бизнес-уровень)
|
||||
|
||||
Принят **вариант (c)** (решение Славы, 08.06): пустая строка эффорта трактуется как
|
||||
«не задано» и откатывается на осмысленный per-role дефолт (а не на CLI-дефолт),
|
||||
**устойчиво** к пустым env. Дополнительно — зафиксировать целевые дефолты в `config.py`
|
||||
и `.env.example`.
|
||||
|
||||
### Целевые значения эффорта (единственный апгрейд — `developer`)
|
||||
| Агент | Эффорт | Обоснование |
|
||||
|-------|--------|-------------|
|
||||
| analyst | high | intelligence-роль |
|
||||
| architect | high | intelligence-роль |
|
||||
| **developer** | **xhigh** | coding/agentic, канон Opus 4.8 → апгрейд с `high` |
|
||||
| reviewer | high | intelligence-роль |
|
||||
| tester | medium | механическая роль |
|
||||
| deployer | medium | механическая роль |
|
||||
|
||||
`developer → xhigh` — единственное изменение относительно текущих config-дефолтов;
|
||||
остальные значения подтверждают текущий замысел и фиксируются устойчиво.
|
||||
|
||||
## 4. Грабли / ограничения (из бизнес-запроса)
|
||||
|
||||
- **Хост-репо / env-правки НЕ переживают деплой**, если положены в git-managed файл
|
||||
(урок 08.06 про docker-compose + TZ). Источник правды для реальных значений —
|
||||
`.env` на хосте (gitignored), канон-шаблон — `.env.example`. Фикс обязан быть
|
||||
**code-side robust**: даже если прод-`.env` снова окажется с пустыми
|
||||
`ORCH_AGENT_EFFORT_*`, эффорт всё равно резолвится в целевые значения.
|
||||
- **Self-hosting:** правка касается инструмента, который сейчас в проде обслуживает и
|
||||
другие проекты. Прод-контейнер `orchestrator` не ронять в рамках задачи; деплой —
|
||||
через штатный `deploy-staging` → `Confirm Deploy`.
|
||||
|
||||
## 5. Не-цели
|
||||
|
||||
- НЕ трогать model-резолв (`resolve_agent_model` — сделан в ORCH-074).
|
||||
- НЕ включать G3 model-routing — все 6 агентов остаются на `claude-opus-4-8`.
|
||||
- НЕ менять значения эффорта сверх согласованных (`high`/`medium`/`xhigh` для
|
||||
developer). Иные значения — отдельное взвешенное решение.
|
||||
|
||||
## 6. Затронутые стороны
|
||||
|
||||
- Все агенты конвейера (analyst → deployer) во всех проектах общего инстанса.
|
||||
- Операторы (правка прод-`.env`), документация (README таблица, `.env.example`).
|
||||
</content>
|
||||
</invoke>
|
||||
110
docs/work-items/ORCH-081/02-trz.md
Normal file
110
docs/work-items/ORCH-081/02-trz.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 02 — ТЗ: ORCH-081 (ORCH-52h)
|
||||
|
||||
**Work Item:** ORCH-081 · **Тип:** багфикс конфигурации · **Repo:** orchestrator
|
||||
|
||||
Документ описывает ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые модули. Конкретный механизм
|
||||
(field_validator vs изменение резолвера) — на усмотрение архитектора; ниже зафиксированы
|
||||
инварианты, которым любая реализация обязана удовлетворять.
|
||||
|
||||
## 1. Задействованные модули
|
||||
|
||||
| Модуль | Роль в задаче |
|
||||
|--------|----------------|
|
||||
| `src/config.py` (`Settings`) | дефолты эффорта; устойчивость к пустому env (ядро фикса) |
|
||||
| `src/agents/launcher.py` | `resolve_agent_effort` / `_resolve_agent_attr` (цепочка резолва), `VALID_EFFORTS`, сборка `--effort` в `_spawn` |
|
||||
| `.env.example` | канон-шаблон значений эффорта по ролям |
|
||||
| `docs/architecture/README.md` | таблица «Модель и эффорт по ролям» (строки ~47–54) |
|
||||
| `CHANGELOG.md` | запись о фиксе |
|
||||
| `tests/test_resolve_agent_effort.py` | расширить кейсами пустого env |
|
||||
|
||||
## 2. Корень бага (точная механика)
|
||||
|
||||
`launcher._resolve_agent_attr` (строки ~104–114):
|
||||
```
|
||||
per_agent = getattr(settings, f"agent_effort_{agent}", "") # '' в проде -> falsy -> skip
|
||||
default = getattr(settings, "agent_effort_default", "") # '' в проде -> falsy -> skip
|
||||
return "" # уровень 4: без флага
|
||||
```
|
||||
Pydantic: `ORCH_AGENT_EFFORT_*=` (пустая строка в env) перебивает дефолт класса →
|
||||
поле `= ''`. Поскольку пустым оказывается **и** `agent_effort_default`, у резолва нет
|
||||
непустого «пола» для отката → `''` → `--effort` не передаётся.
|
||||
|
||||
## 3. Требования к фиксу (вариант c)
|
||||
|
||||
### FR-1. Непустой floor на каждую роль при пустом env
|
||||
При ЛЮБОЙ комбинации пустых `ORCH_AGENT_EFFORT_*` (включая `ORCH_AGENT_EFFORT_DEFAULT=`)
|
||||
`resolve_agent_effort(agent)` обязан вернуть целевое непустое значение для каждой из 6
|
||||
ролей:
|
||||
|
||||
| agent | результат |
|
||||
|-------|-----------|
|
||||
| analyst | `high` |
|
||||
| architect | `high` |
|
||||
| developer | `xhigh` |
|
||||
| reviewer | `high` |
|
||||
| tester | `medium` |
|
||||
| deployer | `medium` |
|
||||
|
||||
Замечание для реализации: floor должен быть **per-role**, а не единым на default —
|
||||
иначе пустой `ORCH_AGENT_EFFORT_TESTER=` снапнется на `high` вместо `medium`. Т.е.
|
||||
«пустая строка трактуется как не-задано» применяется так, чтобы каждая роль получала
|
||||
СВОЙ канонический дефолт, а не общий.
|
||||
|
||||
### FR-2. Приоритет резолва сохраняется
|
||||
Порядок не меняется: project-override (`projects_json.agent_efforts`) > per-agent env >
|
||||
default > floor. Непустой явный env/override по-прежнему ПОБЕЖДАЕТ floor (оператор может
|
||||
осознанно задать, напр., `ORCH_AGENT_EFFORT_DEVELOPER=high`, и это применится).
|
||||
|
||||
### FR-3. Валидация невалидного значения не регрессирует
|
||||
Значение вне `VALID_EFFORTS` (`low|medium|high|xhigh|max`) по-прежнему логируется
|
||||
(`logger.warning`) и **дропается** → `''` (без флага). Floor НЕ должен «спасать» явную
|
||||
опечатку (`turbo`/`ultra`) — поведение ORCH-41 сохраняется (never-break, мусор не
|
||||
уезжает в CLI).
|
||||
|
||||
### FR-4. `developer → xhigh` зафиксирован явно
|
||||
`config.py`: `agent_effort_developer` со значением `xhigh` (сейчас `high`).
|
||||
`.env.example`: `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` (сейчас `high`) + правка комментария
|
||||
про split (developer теперь xhigh, не в группе «thinking → high»).
|
||||
|
||||
### FR-5. `xhigh` принимается CLI-слоем
|
||||
Подтвердить, что `xhigh` присутствует в `VALID_EFFORTS`
|
||||
(`src/agents/launcher.py:22` — уже `frozenset({"low","medium","high","xhigh","max"})`,
|
||||
**присутствует**; добавления не требуется, только верификация тестом). Эффорт реально
|
||||
собирается в команду: `_spawn` строит `effort_flag = f"--effort {effort} "` при непустом
|
||||
`effort` (строка ~434) — путь проброса не менять, только убедиться тестом сборки флага.
|
||||
|
||||
## 4. Изменения API / схемы БД
|
||||
|
||||
- **API endpoints:** нет.
|
||||
- **Схема БД:** нет.
|
||||
- **Конфиг (env-контракт):** значения `ORCH_AGENT_EFFORT_*` неизменны по ИМЕНАМ;
|
||||
меняется лишь дефолт `developer` (high → xhigh) и устойчивость к пустым значениям.
|
||||
Обратная совместимость: непустой явный env работает 1:1 как раньше.
|
||||
|
||||
## 5. Требования к QG checks
|
||||
|
||||
Новых QG checks не требуется. Гейты конвейера не затрагиваются.
|
||||
|
||||
## 6. Артефакты pipeline (обновить в ТОМ ЖЕ PR)
|
||||
|
||||
- `src/config.py` — дефолт developer + устойчивость к пустому env.
|
||||
- `src/agents/launcher.py` — если фикс кладётся в резолвер (на усмотрение архитектора).
|
||||
- `.env.example` — `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + правка комментария split.
|
||||
- `docs/architecture/README.md` — таблица эффорта: developer `high` → `xhigh`; при
|
||||
необходимости — ремарка про floor/устойчивость к пустому env.
|
||||
- `CHANGELOG.md` — запись (`fix:`).
|
||||
- `tests/test_resolve_agent_effort.py` — новые кейсы (см. 04-test-plan.yaml).
|
||||
|
||||
## 7. Операционная часть (вне PR-кода, для деплой-лога)
|
||||
|
||||
- Реальные значения — в прод-`.env` на хосте (gitignored). Рекомендуется привести
|
||||
прод-`.env` к каноне `.env.example` (developer=xhigh, остальные непустые), НО фикс
|
||||
обязан работать и без этого (FR-1). Не коммитить секреты/хост-env в git.
|
||||
- Деплой — через `deploy-staging` (8501) → `Confirm Deploy`. Прод-контейнер не ронять
|
||||
вне штатного хука.
|
||||
|
||||
## 8. Definition of Done
|
||||
|
||||
AC-1…AC-5 из `03-acceptance-criteria.md` выполнены; `pytest -q` зелёный; документация
|
||||
(README + `.env.example` + CHANGELOG) синхронизирована в том же PR; never-break соблюдён.
|
||||
</content>
|
||||
60
docs/work-items/ORCH-081/03-acceptance-criteria.md
Normal file
60
docs/work-items/ORCH-081/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 03 — Критерии приёмки: ORCH-081 (ORCH-52h)
|
||||
|
||||
Каждый критерий — чёткое условие PASS/FAIL. Пустой env моделируется в unit-тестах
|
||||
(установка `agent_effort_* = ""`), проверка «в проде» — операционная (post-deploy).
|
||||
|
||||
## AC-1 — осмысленный непустой эффорт для всех 6 агентов
|
||||
**PASS:** `resolve_agent_effort(agent)` возвращает целевое непустое значение для каждой
|
||||
роли при канонической конфигурации:
|
||||
|
||||
| agent | ожидаемое |
|
||||
|-------|-----------|
|
||||
| analyst | `high` |
|
||||
| architect | `high` |
|
||||
| developer | `xhigh` |
|
||||
| reviewer | `high` |
|
||||
| tester | `medium` |
|
||||
| deployer | `medium` |
|
||||
|
||||
**FAIL:** любой агент возвращает `''` или значение, отличное от таблицы.
|
||||
|
||||
## AC-2 — пустой env НЕ приводит к пустому эффорту (вариант c)
|
||||
**PASS:** при `agent_effort_default = ""` И всех `agent_effort_<role> = ""`
|
||||
(моделирование прод-env, где `ORCH_AGENT_EFFORT_*=` пусты) `resolve_agent_effort` для
|
||||
каждой из 6 ролей возвращает значение по таблице AC-1 (floor per-role срабатывает:
|
||||
developer=`xhigh`, tester/deployer=`medium`, остальные=`high`), а **не** `''`.
|
||||
**FAIL:** хотя бы одна роль при полностью пустом env даёт `''`.
|
||||
|
||||
## AC-3 — эффорт реально пробрасывается в запуск агента
|
||||
**PASS:** в `launcher._spawn` (или эквивалентной сборке) при непустом резолвнутом
|
||||
эффорте формируется `--effort <value> ` во флагах команды; при пустом — флаг
|
||||
отсутствует. Тест сборки флага подтверждает наличие `--effort xhigh ` для developer и
|
||||
`--effort medium ` для tester.
|
||||
**FAIL:** `--effort` отсутствует при непустом значении ИЛИ присутствует при пустом.
|
||||
|
||||
## AC-4 — документация синхронизирована
|
||||
**PASS:** `.env.example` содержит `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` и корректный
|
||||
комментарий про split; таблица «Модель и эффорт по ролям» в
|
||||
`docs/architecture/README.md` показывает developer = `xhigh` (остальные без изменений);
|
||||
`CHANGELOG.md` содержит запись о фиксе.
|
||||
**FAIL:** любой из трёх артефактов рассинхронизирован с фактическими дефолтами config.
|
||||
|
||||
## AC-5 — never-break, тесты зелёные
|
||||
**PASS:**
|
||||
- `pytest -q` целиком зелёный (включая существующие
|
||||
`tests/test_resolve_agent_effort.py` и новые кейсы).
|
||||
- Невалидное значение эффорта (`turbo`/`ultra`/`bogus`) по-прежнему логируется и
|
||||
дропается в `''` (floor его НЕ маскирует) — регрессии валидации ORCH-41 нет.
|
||||
- Непустой явный per-agent env / project-override по-прежнему побеждает floor
|
||||
(приоритет резолва сохранён).
|
||||
- `xhigh ∈ VALID_EFFORTS` (подтверждено тестом).
|
||||
|
||||
**FAIL:** падение любого теста, регрессия валидации/приоритета, либо `xhigh`
|
||||
отвергается как невалидный.
|
||||
|
||||
## AC-6 (операционный, для деплой-стадии) — проверка в проде
|
||||
**PASS:** после деплоя на проде `resolve_agent_effort` для 6 агентов даёт значения
|
||||
AC-1 (проверяется в рантайме прод-инстанса / по логам запуска агента — наличие
|
||||
`--effort` с верным уровнем). Фиксируется в `14-deploy-log.md`.
|
||||
**FAIL:** в проде хотя бы один агент бежит без `--effort` или с неверным уровнем.
|
||||
</content>
|
||||
86
docs/work-items/ORCH-081/04-test-plan.yaml
Normal file
86
docs/work-items/ORCH-081/04-test-plan.yaml
Normal file
@@ -0,0 +1,86 @@
|
||||
work_item: ORCH-081
|
||||
description: >
|
||||
Тест-план фикса ORCH-52h — устойчивость резолва эффорта к пустому env (вариант c) +
|
||||
фиксация целевых дефолтов (developer -> xhigh). Расширяет существующий
|
||||
tests/test_resolve_agent_effort.py. Пустой прод-env моделируется установкой
|
||||
agent_effort_* = "" на settings (через monkeypatch), как уже делают текущие тесты.
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: >
|
||||
Канонические дефолты: resolve_agent_effort для всех 6 ролей даёт
|
||||
analyst/architect/reviewer=high, developer=xhigh, tester/deployer=medium.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-1, FR-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: >
|
||||
Пустой env (вариант c): при agent_effort_default="" И всех
|
||||
agent_effort_<role>="" каждая из 6 ролей возвращает целевое значение по AC-1
|
||||
(НЕ ""). Ключевой кейс бага: developer -> xhigh, tester/deployer -> medium,
|
||||
analyst/architect/reviewer -> high.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
Floor НЕ маскирует опечатку: невалидное значение (default/per-agent/override =
|
||||
'turbo'/'ultra'/'bogus') по-прежнему логируется и дропается в "" (валидация
|
||||
ORCH-41 не регрессирует). Проверить, что floor не подменяет невалидный явный ввод
|
||||
на дефолт.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5, FR-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
Приоритет сохранён: непустой per-agent env побеждает floor/ default
|
||||
(ORCH_AGENT_EFFORT_DEVELOPER=high -> "high", не "xhigh"); project-override
|
||||
побеждает per-agent (agent_efforts={"developer":"xhigh"}).
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5, FR-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
xhigh валиден: xhigh ∈ VALID_EFFORTS и resolve_agent_effort с developer-дефолтом
|
||||
xhigh не дропается.
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5, FR-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: >
|
||||
Сборка флага: при resolve developer=xhigh во флагах присутствует "--effort xhigh ",
|
||||
при tester=medium — "--effort medium "; при пустом эффорте "--effort" отсутствует
|
||||
(mirror логики _spawn, как существующие test_flags_* кейсы).
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: >
|
||||
Документация синхронизирована: .env.example содержит
|
||||
ORCH_AGENT_EFFORT_DEVELOPER=xhigh; README таблица эффорта показывает developer
|
||||
xhigh. (Проверяется ревьюером/тестером по diff; опционально — текстовая ассерта.)
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: >
|
||||
Регрессия существующего набора: весь tests/test_resolve_agent_effort.py +
|
||||
tests/test_resolve_agent_model.py остаются зелёными (never-break ORCH-41/074).
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
</content>
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR-001: Per-role floor для резолва `--effort`, устойчивый к пустому env
|
||||
|
||||
**Work Item:** ORCH-081 (ORCH-52h) · **Эпик:** ORCH-052 (после ORCH-074)
|
||||
**Связанные:** ORCH-41 (резолв model/effort), ORCH-074 (валидация модели, `is_valid_model`)
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
В проде `resolve_agent_effort()` возвращает `''` для всех 6 агентов, хотя в
|
||||
`src/config.py` заданы осмысленные дефолты (`high`/`medium`). Итог: флаг `--effort`
|
||||
не передаётся в Claude CLI, каждый агент бежит на встроенном CLI-дефолте, а не на
|
||||
заявленном уровне. Для Opus 4.8 reasoning-эффорт сильнее влияет на качество, чем у
|
||||
прежних моделей, → прямой удар по предсказуемости качества всего конвейера (включая
|
||||
enduro-trails из общего инстанса).
|
||||
|
||||
### Корень (точная механика)
|
||||
Pydantic Settings трактует **присутствующую** env-переменную — даже пустую
|
||||
(`ORCH_AGENT_EFFORT_DEVELOPER=` без значения) — как явное значение и **перебивает**
|
||||
дефолт класса: поле `= ''`. В проде пусты И per-agent (`ORCH_AGENT_EFFORT_<ROLE>=`),
|
||||
И default (`ORCH_AGENT_EFFORT_DEFAULT=`). Цепочка резолва (`_resolve_agent_attr`):
|
||||
|
||||
```
|
||||
project-override (agent_efforts) → пусто
|
||||
per-agent env ('') → falsy → skip
|
||||
default ('') → falsy → skip
|
||||
→ '' (уровень 4: без флага)
|
||||
```
|
||||
|
||||
Привычный откат «per-agent пуст → взять default» не спасает: откатываться не на что —
|
||||
default тоже пуст. Нужен непустой **per-role** «пол» (floor) ниже default.
|
||||
|
||||
### Дополнительное ограничение (урок 08.06)
|
||||
Хост-правки env, положенные в git-managed файл, **не переживают деплой**. Источник
|
||||
правды реальных значений — `.env` на хосте (gitignored). Значит, фикс обязан быть
|
||||
**code-side robust**: даже если прод-`.env` снова окажется с пустыми
|
||||
`ORCH_AGENT_EFFORT_*`, эффорт всё равно резолвится в целевые значения.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### Вариант A — `field_validator` в `config.py` (coerce пустой → дефолт на уровне поля)
|
||||
Валидатор каждого `agent_effort_*` конвертирует пустую строку в канонический дефолт
|
||||
поля.
|
||||
**Отклонён:** ломает приоритет FR-2. Если per-agent поле всегда непустое, оно ВСЕГДА
|
||||
бьёт `default` (уровень 3 становится мёртвым для роли с пустым env). Сценарий: оператор
|
||||
ставит `ORCH_AGENT_EFFORT_DEFAULT=max`, per-agent оставляет пустыми — намерение «все
|
||||
роли на max», но coercion на уровне поля даст каждой роли её per-role дефолт, а не
|
||||
`max`. Floor обязан стоять **строго ниже** default, а это видно только в резолвере,
|
||||
где доступна вся цепочка приоритетов.
|
||||
|
||||
### Вариант B — explicit hardcoded map `{analyst: high, …}` в `launcher.py`
|
||||
Отдельная константа-карта per-role floor.
|
||||
**Отклонён как первичный:** вводит **второй источник правды** рядом с дефолтами
|
||||
`config.py`. Баг, который мы чиним, — это и есть дрейф/рассинхрон конфигурации;
|
||||
заводить новую поверхность дрейфа концептуально неверно (карту и config надо вручную
|
||||
держать в синхроне).
|
||||
|
||||
### Вариант C — floor в резолвере, значение = class-default поля (ПРИНЯТО)
|
||||
Floor применяется как **последний** уровень в `resolve_agent_effort`, ниже `default`,
|
||||
а его значение берётся из **декларированного class-default** соответствующего поля
|
||||
`Settings` (через `model_fields`), который пустой env НЕ может перебить.
|
||||
|
||||
## Решение
|
||||
|
||||
Фикс кладётся в `resolve_agent_effort` (`src/agents/launcher.py`), `_resolve_agent_attr`
|
||||
остаётся общим с model-резолвом и **не трогается** (floor — effort-специфичен).
|
||||
|
||||
### Цепочка резолва (новая, уровень 4 — floor)
|
||||
```
|
||||
1. project-override (projects_json.agent_efforts[agent]) — непустой побеждает
|
||||
2. per-agent env (settings.agent_effort_<agent>) — непустой побеждает
|
||||
3. global default (settings.agent_effort_default) — непустой побеждает
|
||||
4. per-role FLOOR (class-default поля agent_effort_<agent>) — НОВОЕ, непустой пол
|
||||
↓ (только если все 1–3 пусты)
|
||||
5. валидация VALID_EFFORTS → невалидное дропается в '' (ORCH-41, never-break)
|
||||
```
|
||||
|
||||
### Ключевые инварианты реализации
|
||||
- **Floor = class-default поля, а не instance-значение.** `type(settings).model_fields[f"agent_effort_{agent}"].default` возвращает декларированный дефолт (`high`/`medium`/`xhigh`), который пустой env не клобберит. Это восстанавливает значение, которое pydantic дал бы, не будь спурьозного `VAR=`. **Единый источник правды — `config.py`**: developer-апгрейд на `xhigh` делается одной правкой поля, floor подтягивается автоматически.
|
||||
- **Floor применяется ДО валидации и ТОЛЬКО при пустом резолве.** Порядок критичен для FR-3: явная опечатка (`turbo`) — непустая, поэтому floor НЕ применяется, и значение штатно дропается валидацией в `''`. Floor не маскирует мусор.
|
||||
- **Floor — строго уровень 4 (ниже default).** Непустой явный env/override/`default` по-прежнему побеждает floor (FR-2). Floor срабатывает лишь когда сконфигурировать эффорт забыли/занулили на всех уровнях.
|
||||
- **Unknown-agent fallback:** если поля `agent_effort_<agent>` нет (имя не из 6 ролей), floor деградирует на class-default `agent_effort_default` (`high`) — непустой безопасный пол, never-break.
|
||||
|
||||
### Сопутствующая правка config (FR-4)
|
||||
`config.py`: `agent_effort_developer` `high → xhigh` (канон Opus 4.8: coding/agentic роль).
|
||||
Это единственное изменение значений; остальные (`analyst/architect/reviewer=high`,
|
||||
`tester/deployer=medium`) подтверждаются и фиксируются устойчиво. Поскольку floor =
|
||||
class-default, апгрейд автоматически становится и новым floor для developer.
|
||||
|
||||
### Целевые значения (floor при полностью пустом env)
|
||||
| agent | floor |
|
||||
|-------|-------|
|
||||
| analyst | high |
|
||||
| architect | high |
|
||||
| developer | **xhigh** |
|
||||
| reviewer | high |
|
||||
| tester | medium |
|
||||
| deployer | medium |
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы**
|
||||
- Code-side robust: пустой прод-`.env` больше не обнуляет эффорт; целевые уровни
|
||||
гарантированы без зависимости от хост-правок, которые не переживают деплой.
|
||||
- Единый источник правды (`config.py`); нулевой риск дрейфа floor-карты.
|
||||
- Приоритет резолва и контракт ORCH-41 сохранены 1:1; непустой явный конфиг работает
|
||||
как раньше (полная обратная совместимость).
|
||||
- Валидация ORCH-41 не регрессирует — опечатки по-прежнему дропаются, never-break.
|
||||
|
||||
**Минусы / ограничения**
|
||||
- Лёгкая зависимость от pydantic-v2 API (`model_fields[...].default`) — публичный
|
||||
стабильный атрибут, но это связь с внутренним устройством Settings. Замокать в тестах
|
||||
тривиально.
|
||||
- «CLI-дефолт без флага» как исход для 6 штатных ролей становится недостижим — это
|
||||
намеренно: для известных ролей всегда есть непустой пол. Unknown-agent сохраняет
|
||||
безопасный непустой fallback.
|
||||
|
||||
**Не затрагивается**
|
||||
- API endpoints — нет. Схема БД — нет. QG checks / гейты конвейера — нет.
|
||||
Model-резолв (ORCH-074) — нет. Путь проброса `--effort` в `_spawn` (стр. ~434) — нет
|
||||
(только верификация тестом, FR-3/FR-5).
|
||||
|
||||
## Деплой (self-hosting)
|
||||
Правка касается инструмента, обслуживающего в проде и другие проекты. Прод-контейнер
|
||||
`orchestrator` не ронять в рамках задачи; деплой — штатно `deploy-staging` (8501) →
|
||||
`Confirm Deploy`. Рекомендуется привести прод-`.env` к каноне `.env.example`
|
||||
(developer=xhigh, остальные непустые), НО фикс обязан работать и без этого (FR-1).
|
||||
Проверка в проде (AC-6) фиксируется в `14-deploy-log.md`.
|
||||
17
docs/work-items/ORCH-081/10-tech-risks.md
Normal file
17
docs/work-items/ORCH-081/10-tech-risks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 10 — Технические риски: ORCH-081 (ORCH-52h)
|
||||
|
||||
| ID | Риск | Вероятн. | Влияние | Митигация |
|
||||
|----|------|----------|---------|-----------|
|
||||
| R-1 | **Floor маскирует опечатку.** Если floor применить ПОСЛЕ/ВМЕСТО валидации, мусорное `turbo` подменится на floor вместо дропа → регрессия never-break ORCH-41. | низк. | средн. | Floor строго ДО валидации и ТОЛЬКО при пустом резолве (значение `turbo` непустое → floor не трогается → дроп). Покрыть тестом FR-3 (опечатка → `''`). |
|
||||
| R-2 | **Floor перебивает явный конфиг.** Ошибка порядка → floor встанет выше default/per-agent и `ORCH_AGENT_EFFORT_DEFAULT=max` перестанет применяться. | низк. | средн. | Floor — строго уровень 4 (ниже default). Тест FR-2: непустой default/per-agent/override побеждает floor. |
|
||||
| R-3 | **Зависимость от pydantic-internal** `model_fields[...].default`. Будущий мажор pydantic может сменить API → floor отвалится. | низк. | низк. | Публичный стабильный атрибут pydantic v2. Тест AC-1/AC-2 поймает регрессию сразу (floor вернёт не то/пусто). Фиксируется версией pydantic в зависимостях. |
|
||||
| R-4 | **Дрейф floor vs config** при выборе hardcoded-карты. | — | — | Снят архитектурно: floor = class-default поля, единый источник правды (см. ADR-001, вариант B отклонён). |
|
||||
| R-5 | **Self-hosting:** правка резолва эффорта затрагивает запуск ВСЕХ агентов всех проектов общего инстанса; ошибка ломает конвейер enduro-trails тоже. | низк. | высок. | Обязательный `deploy-staging` (8501) перед прод-деплоем; прод-контейнер не ронять вне штатного хука; `Confirm Deploy`-гейт. Post-deploy проверка AC-6 по логам запуска агента. |
|
||||
| R-6 | **Прод-`.env` снова с пустыми `ORCH_AGENT_EFFORT_*`** после деплоя (урок 08.06: git-managed env не переживает). | средн. | низк. | Именно это и закрывает фикс (FR-1, code-side robust): эффорт резолвится в floor независимо от состояния `.env`. Приведение `.env` к каноне — рекомендация, не зависимость. |
|
||||
| R-7 | **`xhigh` не принимается CLI-слоем.** developer-апгрейд бессмыслен, если `xhigh ∉ VALID_EFFORTS`. | очень низк. | средн. | `xhigh` уже в `VALID_EFFORTS` (`launcher.py:22`); добавления не требуется — только верификация тестом (FR-5). |
|
||||
|
||||
## Сводный вывод
|
||||
Изменение локализовано в `resolve_agent_effort` + один дефолт `config.py`; не трогает
|
||||
API, схему БД, QG-гейты, model-резолв и путь проброса `--effort`. Главный остаточный
|
||||
риск — операционный (R-5, self-hosting), снимается штатным staging-гейтом. Контракт
|
||||
ORCH-41/ORCH-074 сохранён, обратная совместимость полная.
|
||||
57
docs/work-items/ORCH-081/12-review.md
Normal file
57
docs/work-items/ORCH-081/12-review.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-081
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-081 (ORCH-52h) — устойчивость резолва `--effort` к пустому env + developer→xhigh
|
||||
|
||||
## Summary
|
||||
Фикс конфигурационного бага: в проде `resolve_agent_effort()` возвращал `''` для всех 6 агентов (пустые `ORCH_AGENT_EFFORT_*=` перебивают class-default pydantic), `--effort` не доходил до Claude CLI. Решение — вариант C по ADR-001: непустой **per-role floor** уровня 4 в `resolve_agent_effort`, значение = декларированный class-default поля `agent_effort_<agent>` через `model_fields[...].default`. `developer` поднят `high→xhigh` в `config.py` (единый источник правды, floor подтягивается автоматически).
|
||||
|
||||
Реализация полностью соответствует ТЗ и ADR; вся документация синхронизирована в том же бранче; `pytest -q` — **1031 passed**.
|
||||
|
||||
## Соответствие ТЗ (FR-1…FR-5)
|
||||
- **FR-1** per-role floor при пустом env → каждая роль получает свой канон (`_agent_effort_floor`, TC-02). ✓
|
||||
- **FR-2** приоритет резолва сохранён: явный env/override/default побеждают floor (TC-04: `test_explicit_env_beats_floor`, `test_default_beats_floor`, `test_project_override_beats_floor`). ✓
|
||||
- **FR-3** валидация не регрессирует: непустая опечатка (`turbo`) не доходит до floor → дропается в `''` (TC-03 `test_floor_does_not_mask_typo`). ✓
|
||||
- **FR-4** `agent_effort_developer = "xhigh"` в `config.py`; `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + правка комментария split в `.env.example`. ✓
|
||||
- **FR-5** `xhigh ∈ VALID_EFFORTS`; сборка флага `--effort xhigh `/`--effort medium ` подтверждена (TC-05/TC-06). ✓
|
||||
|
||||
## Соответствие ADR-001
|
||||
- Floor как **строго уровень 4** ниже default, в резолвере — ✓ (вариант C, не field_validator/не hardcoded map).
|
||||
- Floor = **class-default поля** (`type(settings).model_fields[...].default`), который пустой env перебить не может — ✓.
|
||||
- `_resolve_agent_attr` (общий с model-резолвом) **не тронут** — ✓.
|
||||
- Floor применяется **ДО валидации и только при пустом резолве** — ✓.
|
||||
- Unknown-agent деградирует на class-default `agent_effort_default` (`high`) — ✓ (`test_empty_env_unknown_agent_floor_is_default`).
|
||||
- Никаких изменений API / схемы БД / QG / model-резолва / пути проброса в `_spawn` — ✓.
|
||||
|
||||
## Качество кода и тестов
|
||||
- Чистый leaf-helper, подробные docstrings, контракт never-raise соблюдён.
|
||||
- Тесты содержательные, покрывают все AC/FR (канон-дефолты, floor per-role, не-маскирование опечатки, приоритет на 3 уровнях, `xhigh`-валидность, сборка флага + негативные кейсы).
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- `tests/test_resolve_agent_effort.py:218-219` — продублирована строка `assert "--fallback-model" not in flags` в `test_flags_absent_when_model_empty`. Безвредно, можно убрать при случае.
|
||||
|
||||
## Документация
|
||||
Изменён `src/` → документация обновлена в том же бранче (доку-гейт пройден):
|
||||
- `docs/architecture/README.md` — таблица «Модель и эффорт по ролям»: developer = `xhigh`; добавлена ремарка про per-role floor / устойчивость к пустому env (AC-4). ✓
|
||||
- `.env.example` — `ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + комментарий split/floor (AC-4). ✓
|
||||
- `CHANGELOG.md` — запись `fix:` с разбором корня/фикса. ✓
|
||||
- `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md` — присутствует (Accepted). ✓
|
||||
|
||||
## Примечание (вне scope ревью)
|
||||
- AC-6 — операционная проверка в проде после деплоя, фиксируется в `14-deploy-log.md` на стадии deploy. К коду PR не относится.
|
||||
- `git diff main...HEAD` показывает также код ORCH-074 (`is_valid_model`/`resolve_agent_model`) из-за устаревшего локального `main`; собственно изменения ORCH-081 — коммит `56bf303` (+ README обновлён в линии бранча). На ревью это не влияет: HEAD-состояние корректно по всем осям.
|
||||
61
docs/work-items/ORCH-081/13-test-report.md
Normal file
61
docs/work-items/ORCH-081/13-test-report.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-081
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-081 (ORCH-52h)
|
||||
|
||||
Устойчивость резолва `--effort` к пустому env (вариант c) + фиксация целевых
|
||||
дефолтов (developer → xhigh).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Repo/branch: orchestrator @ `feature/ORCH-081-orch-52h-env-config` (worktree)
|
||||
- prod `/health`: ok (8500) · staging `/health`: ok (8501) — не трогались
|
||||
- Дата: 2026-06-08
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Покрытие | Результат |
|
||||
|-------|----------|----------|-----------|
|
||||
| TC-01 | Канонические дефолты: 6 ролей дают high/high/xhigh/high/medium/medium | AC-1, FR-4 | PASS |
|
||||
| TC-02 | Пустой env (вариант c): per-role floor, developer→xhigh, tester/deployer→medium, остальные→high (НЕ "") | AC-2 | PASS |
|
||||
| TC-03 | Floor НЕ маскирует опечатку: `turbo`/`ultra`/`bogus` логируется и дропается в "" | AC-5, FR-3 | PASS |
|
||||
| TC-04 | Приоритет сохранён: непустой per-agent env / project-override побеждают floor/default | AC-5, FR-2 | PASS |
|
||||
| TC-05 | `xhigh ∈ VALID_EFFORTS` и не дропается | AC-5, FR-5 | PASS |
|
||||
| TC-06 | Сборка флага: `--effort xhigh ` (developer), `--effort medium ` (tester); пустой → флаг отсутствует | AC-3 | PASS |
|
||||
| TC-07 | Документация синхронизирована: `.env.example` DEVELOPER=xhigh, README таблица developer=xhigh | AC-4 | PASS |
|
||||
| TC-08 | Регрессия: весь набор test_resolve_agent_effort.py + полный регресс зелёные | AC-5 | PASS |
|
||||
|
||||
### Сопоставление с критериями приёмки
|
||||
- **AC-1** — `test_canonical_effort_all_roles[*]` (6 параметров) → PASS.
|
||||
- **AC-2** — `test_empty_env_falls_back_to_per_role_floor[*]` (6 параметров) + `test_empty_env_unknown_agent_floor_is_default` → PASS.
|
||||
- **AC-3** — `test_flags_present_when_configured`, `test_flags_effort_per_role`, `test_flags_absent_when_effort_empty` → PASS.
|
||||
- **AC-4** — verified по diff: `src/config.py:108` `agent_effort_developer = "xhigh"`; `.env.example:48` `ORCH_AGENT_EFFORT_DEVELOPER=xhigh`; `docs/architecture/README.md` таблица developer=`xhigh`; `CHANGELOG.md` содержит запись `fix:` → PASS.
|
||||
- **AC-5** — `test_floor_does_not_mask_typo`, `test_*_beats_floor`, `test_xhigh_is_valid`, `test_invalid_*_dropped` + полный регресс зелёный → PASS.
|
||||
- **AC-6** — операционный, вне scope стадии testing: проверяется в рантайме прода на стадии `deploy`, фиксируется в `14-deploy-log.md`.
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}`
|
||||
- `GET /status` → HTTP 200
|
||||
- `GET /queue` → HTTP 200
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Целевой файл задачи:
|
||||
```
|
||||
tests/test_resolve_agent_effort.py ... 29 passed, 1 warning in 0.36s
|
||||
```
|
||||
|
||||
Полный регресс:
|
||||
```
|
||||
........................................................................ [ 97%]
|
||||
....................... [100%]
|
||||
1031 passed, 1 warning in 27.02s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, не относится к задаче, предсуществующий.)
|
||||
|
||||
## Итог
|
||||
**PASS** — все 8 TC пройдены, критерии AC-1…AC-5 выполнены (AC-6 операционный, для стадии deploy), полный регресс `1031 passed`, smoke API зелёный. Прод/staging-контейнеры не затрагивались.
|
||||
12
docs/work-items/ORCH-081/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-081/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-081
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
27
docs/work-items/ORCH-081/15-staging-log.md
Normal file
27
docs/work-items/ORCH-081/15-staging-log.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T19:47:45+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. Exit code 0 → advance.
|
||||
|
||||
Canonical run inside the `orchestrator-staging` container (ORCH-048, ADR-001) via the
|
||||
Docker Engine API over the unix socket (docker CLI unavailable in the agent container):
|
||||
|
||||
```
|
||||
python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
Result: **8/10 checks PASS**, all REAL checks green.
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
The two waived checks (C9a/C9b) are the known sandbox-infra-only checks tolerated under
|
||||
ORCH-061 (SANDBOX bot accounts are not members of the sandbox Plane project — not a pipeline
|
||||
regression). All pipeline (REAL) checks A1–A3, B4–B6, C7–C8 passed.
|
||||
49
docs/work-items/ORCH-082/15-staging-log.md
Normal file
49
docs/work-items/ORCH-082/15-staging-log.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T21:55:49Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live staging environment (`orchestrator-staging`, port 8501),
|
||||
run inside the container per the canonical method (ORCH-048, ADR-001):
|
||||
|
||||
```
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
## Result: 8/10 checks PASS — exit code 0 (SUCCESS)
|
||||
|
||||
- REAL failed: none
|
||||
- SANDBOX_INFRA failed (waived per ORCH-061): C9a, C9b
|
||||
|
||||
All REAL pipeline checks (Block A SMOKE, Block B ACCESS, C7/C8) passed. The only failures are the
|
||||
two infra-only sandbox checks (C9a branch-in-sandbox / C9b analyst-job-enqueued), which depend on
|
||||
SANDBOX bot accounts being members of the sandbox project — not on the pipeline. Tolerance is enabled
|
||||
(`staging_infra_tolerance_enabled=True`), so these are waived and the script exits 0 (fail-closed for
|
||||
any REAL failure remains intact).
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a …', 'C9b …'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
### Block-by-block summary
|
||||
|
||||
| Block | Check | Result |
|
||||
|-------|-------|--------|
|
||||
| A | A1 GET /health → 200 status=ok | ✓ PASS |
|
||||
| A | A2 GET /queue → 200 with counts/max_concurrency/resilience | ✓ PASS |
|
||||
| A | A3 ORCH_STAGING=true (not prod) | ✓ PASS |
|
||||
| B | B4 Plane: sandbox project accessible | ✓ PASS |
|
||||
| B | B5 Gitea: orchestrator-sandbox accessible, push=true | ✓ PASS |
|
||||
| B | B6 Registry: sandbox present, prod ET/ORCH absent | ✓ PASS |
|
||||
| C | C7 Create issue in Plane SANDBOX | ✓ PASS |
|
||||
| C | C8 Trigger pipeline via /webhook/plane | ✓ PASS |
|
||||
| C | C9a Branch appears in orchestrator-sandbox | ✗ FAIL (sandbox-infra, waived) |
|
||||
| C | C9b Analyst job enqueued in staging queue | ✗ FAIL (sandbox-infra, waived) |
|
||||
|
||||
Cleanup completed: test Plane issue deleted (HTTP 204); no branch created to delete.
|
||||
@@ -2,6 +2,7 @@ import subprocess
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import signal
|
||||
import time
|
||||
@@ -20,6 +21,36 @@ logger = logging.getLogger("orchestrator.launcher")
|
||||
# never passed through to the CLI.
|
||||
VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
||||
|
||||
# ORCH-074 (G2): structural validity check for a Claude CLI model name. We use a
|
||||
# FORMAT check (^claude-…$), not a static allowlist, on purpose: an allowlist
|
||||
# recreates the exact rot we kill in G1 — it silently drops a CORRECT newer model
|
||||
# (e.g. claude-opus-4-9) the day Anthropic ships it (never-break working against
|
||||
# the operator). The final authority on whether a model exists is the Claude CLI
|
||||
# itself, not our code; a format check is forward-compatible (new versions pass
|
||||
# without code edits) while still catching the real failure classes: another
|
||||
# provider (gpt-4), empty/whitespace, garbage chars, wrong prefix (claud-opus-typo).
|
||||
# The claude- prefix is hardcoded here because the orchestrator is bound to the
|
||||
# Claude CLI (CLAUDE_BIN); the canonical model VERSION lives ONLY in
|
||||
# settings.agent_model_default, never here. See ADR-001 (ORCH-074).
|
||||
_MODEL_NAME_RE = re.compile(r"^claude-[a-z0-9.-]+$")
|
||||
|
||||
|
||||
def is_valid_model(name: str) -> bool:
|
||||
"""ORCH-074 (G2): True iff ``name`` is a structurally valid Claude model name.
|
||||
|
||||
A valid name, after ``strip()``, is non-empty, starts with ``claude-`` and
|
||||
contains only lowercase letters, digits, dots and dashes. Anything else
|
||||
(empty/whitespace, another provider like ``gpt-4``, a wrong prefix, illegal
|
||||
characters) is invalid. This is the single predicate used by BOTH
|
||||
``resolve_agent_model`` and the inline ``--fallback-model`` read in ``_spawn``
|
||||
so a typo can never reach the CLI (never-break). It is a structural guard, not
|
||||
a registry of existing models — a structurally valid typo (``claude-opus-typo``)
|
||||
is left for the CLI to reject. Never raises.
|
||||
"""
|
||||
if not name:
|
||||
return False
|
||||
return bool(_MODEL_NAME_RE.match(name.strip()))
|
||||
|
||||
# ORCH-061: action stages whose success is an ACTION (restart/retag), not a src
|
||||
# edit — so "no changes to commit" is EXPECTED there, not under-delivery (FR-3).
|
||||
_ACTION_STAGES = frozenset({"deploy-staging", "deploy"})
|
||||
@@ -83,26 +114,94 @@ def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix,
|
||||
return ""
|
||||
|
||||
|
||||
def _agent_model_candidates(agent: str, project_id: str = None):
|
||||
"""Yield non-empty model candidates in ORCH-41 priority order.
|
||||
|
||||
Same priority as _resolve_agent_attr (project-override > per-agent env >
|
||||
global default), but as a generator so resolve_agent_model can validate each
|
||||
level and SKIP an invalid one (ORCH-074 G2) instead of returning the first
|
||||
non-empty value blindly. Empty levels are simply not yielded.
|
||||
"""
|
||||
if project_id:
|
||||
from ..projects import get_project_by_plane_id
|
||||
proj = get_project_by_plane_id(project_id)
|
||||
if proj is not None:
|
||||
override = getattr(proj, "agent_models", {}).get(agent)
|
||||
if override:
|
||||
yield override
|
||||
per_agent = getattr(settings, f"agent_model_{agent}", "")
|
||||
if per_agent:
|
||||
yield per_agent
|
||||
default = getattr(settings, "agent_model_default", "")
|
||||
if default:
|
||||
yield default
|
||||
|
||||
|
||||
def resolve_agent_model(agent: str, project_id: str = None) -> str:
|
||||
"""ORCH-41: resolve the LLM model for an agent (optionally per-project).
|
||||
|
||||
Returns "" when no model is configured at any level -> caller omits --model
|
||||
and the CLI default applies. See _resolve_agent_attr for the priority order.
|
||||
ORCH-074 (G2): the resolved name is validated with is_valid_model BEFORE it is
|
||||
returned. An invalid (structurally garbage) value at any level is logged and
|
||||
SKIPPED — resolution falls through to the next valid level (project-override
|
||||
invalid -> per-agent env -> default); if no level yields a valid name the
|
||||
function returns "" so the caller omits --model and the CLI default applies.
|
||||
The ORCH-41 priority order and signature are unchanged; validation is layered
|
||||
on top. Never raises and never returns garbage that could reach --model.
|
||||
"""
|
||||
return _resolve_agent_attr(
|
||||
agent, project_id,
|
||||
project_map_attr="agent_models",
|
||||
env_attr_prefix="agent_model_",
|
||||
default_attr="agent_model_default",
|
||||
)
|
||||
for value in _agent_model_candidates(agent, project_id):
|
||||
if is_valid_model(value):
|
||||
return value
|
||||
logger.warning(
|
||||
f"Invalid model name '{value}' for agent '{agent}' "
|
||||
f"(expected '^claude-…'); skipping to next resolution level / CLI default"
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _agent_effort_floor(agent: str) -> str:
|
||||
"""ORCH-081 (ORCH-52h): per-role non-empty floor for --effort resolution.
|
||||
|
||||
Returns the DECLARED class-default of the ``agent_effort_<agent>`` field on
|
||||
Settings (e.g. developer -> ``xhigh``, tester/deployer -> ``medium``, the rest
|
||||
-> ``high``). This is the value pydantic WOULD have used were it not clobbered
|
||||
by a spurious empty env var (``ORCH_AGENT_EFFORT_<ROLE>=``): the class-default
|
||||
is fixed in the class body and a present-but-empty env value cannot override it,
|
||||
so it is a robust floor even when the host ``.env`` zeroes every effort var.
|
||||
|
||||
config.py is the single source of truth: upgrading developer to ``xhigh`` there
|
||||
automatically raises the floor here — no second map to keep in sync (ADR-001).
|
||||
|
||||
Unknown agent (a name outside the 6 roles) has no ``agent_effort_<agent>``
|
||||
field; we degrade to the class-default of ``agent_effort_default`` (``high``),
|
||||
a safe non-empty floor. Never raises.
|
||||
"""
|
||||
fields = type(settings).model_fields
|
||||
for key in (f"agent_effort_{agent}", "agent_effort_default"):
|
||||
field = fields.get(key)
|
||||
if field is not None and field.default:
|
||||
return field.default
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_agent_effort(agent: str, project_id: str = None) -> str:
|
||||
"""ORCH-41: resolve the --effort level for an agent (optionally per-project).
|
||||
|
||||
Same priority as resolve_agent_model. The resolved value is validated against
|
||||
VALID_EFFORTS; an invalid value is logged and dropped (returns "") so a typo
|
||||
in env/projects_json can never pass a bad flag to the CLI.
|
||||
Same priority as resolve_agent_model, with one extra level below the global
|
||||
default (ORCH-081 / ADR-001):
|
||||
1. project-override (projects_json.agent_efforts[agent])
|
||||
2. per-agent env (settings.agent_effort_<agent>)
|
||||
3. global default (settings.agent_effort_default)
|
||||
4. per-role FLOOR (class-default of agent_effort_<agent>) — NEW
|
||||
|
||||
The floor only kicks in when levels 1-3 are all empty (the prod bug: a present
|
||||
but empty ``ORCH_AGENT_EFFORT_*=`` clobbers every default to ''), guaranteeing
|
||||
a non-empty target effort for the 6 known roles regardless of host .env state.
|
||||
|
||||
The floor is applied BEFORE validation and ONLY to an empty resolve, so it
|
||||
never masks a typo: an explicit invalid value (e.g. ``turbo``) is non-empty,
|
||||
skips the floor, and is logged + dropped to "" exactly as in ORCH-41 (the
|
||||
resolved value is validated against VALID_EFFORTS; an invalid value can never
|
||||
pass a bad flag to the CLI). Never raises.
|
||||
"""
|
||||
value = _resolve_agent_attr(
|
||||
agent, project_id,
|
||||
@@ -110,6 +209,11 @@ def resolve_agent_effort(agent: str, project_id: str = None) -> str:
|
||||
env_attr_prefix="agent_effort_",
|
||||
default_attr="agent_effort_default",
|
||||
)
|
||||
if not value:
|
||||
# Levels 1-3 all empty (typically a prod .env with empty ORCH_AGENT_EFFORT_*):
|
||||
# fall through to the per-role floor (class-default). Applied before
|
||||
# validation but only here, so a typo (non-empty) never reaches this branch.
|
||||
value = _agent_effort_floor(agent)
|
||||
if value and value not in VALID_EFFORTS:
|
||||
logger.warning(
|
||||
f"Invalid effort '{value}' for agent '{agent}' "
|
||||
@@ -371,7 +475,17 @@ class AgentLauncher:
|
||||
effort = resolve_agent_effort(agent, project_id)
|
||||
model_flag = f"--model {model} " if model else ""
|
||||
effort_flag = f"--effort {effort} " if effort else ""
|
||||
# ORCH-074 (G2): agent_fallback_model is read directly here, bypassing
|
||||
# resolve_agent_model, so the same validator must guard this point too —
|
||||
# otherwise a typo in ORCH_AGENT_FALLBACK_MODEL would slip into
|
||||
# --fallback-model (never-break violation). Empty value -> no flag, exactly
|
||||
# as before (is_valid_model("") is False but the `if fb` short-circuits).
|
||||
fb = settings.agent_fallback_model
|
||||
if fb and not is_valid_model(fb):
|
||||
logger.warning(
|
||||
f"Invalid fallback model '{fb}'; dropping --fallback-model"
|
||||
)
|
||||
fb = ""
|
||||
fb_flag = f"--fallback-model {fb} " if fb else ""
|
||||
|
||||
# No git fetch/checkout here: ensure_worktree() already put the worktree on
|
||||
|
||||
@@ -97,13 +97,15 @@ class Settings(BaseSettings):
|
||||
agent_model_deployer: str = ""
|
||||
|
||||
# ORCH-41: per-agent effort / reasoning level: low|medium|high|xhigh|max.
|
||||
# Empty -> agent_effort_default. Same resolution order as model. Default split:
|
||||
# thinking agents (analyst/architect/developer/reviewer) -> high; mechanical
|
||||
# agents (tester/deployer) -> medium.
|
||||
# Empty -> agent_effort_default. Same resolution order as model. Default split
|
||||
# (ORCH-081/ORCH-52h): thinking agents (analyst/architect/reviewer) -> high;
|
||||
# developer -> xhigh (coding/agentic role, Opus 4.8 canon); mechanical agents
|
||||
# (tester/deployer) -> medium. These class-defaults are ALSO the per-role floor
|
||||
# used by resolve_agent_effort when the env is empty (single source of truth).
|
||||
agent_effort_default: str = "high"
|
||||
agent_effort_analyst: str = "high"
|
||||
agent_effort_architect: str = "high"
|
||||
agent_effort_developer: str = "high"
|
||||
agent_effort_developer: str = "xhigh"
|
||||
agent_effort_reviewer: str = "high"
|
||||
agent_effort_tester: str = "medium"
|
||||
agent_effort_deployer: str = "medium"
|
||||
@@ -396,6 +398,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
130
src/db.py
@@ -123,6 +123,24 @@ def init_db():
|
||||
# tracker can show "твоё время" without recomputing from activity history.
|
||||
_ensure_column(conn, "tasks", "brd_review_started_at", "TEXT")
|
||||
_ensure_column(conn, "tasks", "brd_review_ended_at", "TEXT")
|
||||
# ORCH-026 (Level B): declarative task dependencies. job_deps stores the
|
||||
# directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The
|
||||
# scheduler gate in claim_next_job keeps B queued until every A reaches
|
||||
# tasks.stage='done'. Purely ADDITIVE (CREATE TABLE/INDEX IF NOT EXISTS, no
|
||||
# change to jobs/tasks/agent_runs/events columns) -> idempotent and safe on
|
||||
# the live shared prod DB (enduro-trails data untouched). The logical FK on
|
||||
# tasks.id is intentional (no REFERENCES, mirrors jobs.task_id) so the
|
||||
# migration cannot fail on a pre-existing DB. See 08-data-requirements.md.
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS job_deps (
|
||||
task_id INTEGER NOT NULL,
|
||||
depends_on_task_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (task_id, depends_on_task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -466,12 +484,28 @@ def claim_next_job() -> dict | None:
|
||||
so the SELECT+UPDATE pair is consistent. Returns the claimed job dict or None
|
||||
when the queue is empty.
|
||||
"""
|
||||
# ORCH-026 (Level B, B-2): scheduler dependency gate. When task_deps_enabled
|
||||
# is on, a job whose task has an UNFINISHED declared dependency
|
||||
# (job_deps.depends_on_task_id -> a task with stage != 'done') is NOT
|
||||
# claimable -> it stays 'queued' without occupying a max_concurrency slot.
|
||||
# Jobs with a NULL task_id (no task) or with no job_deps rows are unaffected
|
||||
# (NOT EXISTS is True). Kill-switch off -> the clause is omitted -> 1:1 the
|
||||
# ORCH-1 query. The gate reads only the DB (offline-safe hot path).
|
||||
dep_gate = ""
|
||||
if getattr(settings, "task_deps_enabled", False):
|
||||
dep_gate = (
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
|
||||
" WHERE d.task_id = jobs.task_id AND t.stage != 'done'"
|
||||
") "
|
||||
)
|
||||
conn = get_db()
|
||||
try:
|
||||
while True:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM jobs WHERE status='queued' "
|
||||
"AND (available_at IS NULL OR available_at <= datetime('now')) "
|
||||
f"{dep_gate}"
|
||||
"ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
@@ -705,6 +739,102 @@ def recent_jobs(limit: int = 10) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-026 (Level B): declarative task-dependency helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_dependency(task_id: int, depends_on_task_id: int) -> bool:
|
||||
"""Declare that task ``task_id`` (B) is blocked-by ``depends_on_task_id`` (A).
|
||||
|
||||
Idempotent INSERT OR IGNORE against the job_deps PK (re-declaring the same
|
||||
edge is a no-op). A self-edge (task depends on itself) is rejected — it would
|
||||
deadlock the task forever and can never be satisfied. never-raise
|
||||
(self-hosting safety, AC-G1): any DB error -> returns False, the caller must
|
||||
not crash the webhook / worker. Returns True iff a NEW edge row was inserted.
|
||||
"""
|
||||
if task_id is None or depends_on_task_id is None:
|
||||
return False
|
||||
if task_id == depends_on_task_id:
|
||||
return False
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) "
|
||||
"VALUES (?, ?)",
|
||||
(task_id, depends_on_task_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_dependencies(task_id: int) -> list[int]:
|
||||
"""Return the list of depends_on_task_id (A) that ``task_id`` (B) waits for.
|
||||
|
||||
never-raise: any DB error -> [] (conservative: caller treats the task as
|
||||
having no declared dependency rather than crashing).
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT depends_on_task_id FROM job_deps WHERE task_id = ?",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_dependency_edges() -> list[tuple[int, int]]:
|
||||
"""Return ALL declared edges as ``(task_id, depends_on_task_id)`` tuples.
|
||||
|
||||
Used by the cycle detector (DFS over the whole declared graph) and the
|
||||
/queue snapshot. never-raise -> [] on any DB error.
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT task_id, depends_on_task_id FROM job_deps"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [(r[0], r[1]) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_unfinished_dependencies(task_id: int) -> list[dict]:
|
||||
"""Return the UNFINISHED dependencies of ``task_id`` (A's not yet 'done').
|
||||
|
||||
Each dict carries the predecessor's ``id``, ``work_item_id`` and ``stage``
|
||||
so the readiness gate / Telegram waiting-line can name what B is waiting for.
|
||||
never-raise -> [] on any DB error (treated as "ready", consistent with the
|
||||
scheduler omitting the gate on failure).
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id AS id, t.work_item_id AS work_item_id, t.stage AS stage "
|
||||
"FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
|
||||
"WHERE d.task_id = ? AND t.stage != 'done'",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-1b (resilience): transient backoff helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -148,6 +148,7 @@ async def queue():
|
||||
from .job_reaper import reaper
|
||||
from . import post_deploy
|
||||
from . import merge_gate
|
||||
from . import task_deps
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
@@ -157,5 +158,8 @@ async def queue():
|
||||
"reaper": reaper.status(),
|
||||
"post_deploy": post_deploy.status(),
|
||||
"merge_verify": merge_gate.merge_verify_status(),
|
||||
# ORCH-026 (G-2): declarative task-dependency observability (read-only,
|
||||
# NOT a source of truth) — declared edges, blocked tasks, detected cycle.
|
||||
"task_deps": task_deps.snapshot(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
@@ -380,6 +380,22 @@ def render_task_tracker(task_id: int) -> str:
|
||||
status_line = f"\U0001f4cd {status_label}"
|
||||
lines = [header, status_line, bar]
|
||||
|
||||
# ORCH-026 (B-4): waiting-line for a task blocked by an unfinished declared
|
||||
# dependency. Shows WHAT the task is waiting on ("⏳ ждёт ORCH-NNN"),
|
||||
# so the single tracker card (invariant preserved) makes the wait visible.
|
||||
# Never breaks the render: any error -> no waiting-line.
|
||||
if not done:
|
||||
try:
|
||||
from . import task_deps
|
||||
from .config import settings as _settings
|
||||
if getattr(_settings, "task_deps_enabled", False):
|
||||
ready, waiting_on = task_deps.is_task_ready(task_id)
|
||||
if not ready and waiting_on:
|
||||
waits = ", ".join(link_for(w) for w in waiting_on)
|
||||
lines.append(f"⏳ ждёт {waits}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _stage_line(label, run):
|
||||
usage = {
|
||||
"input_tokens": run["input_tokens"],
|
||||
|
||||
@@ -433,6 +433,72 @@ def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> str
|
||||
return None
|
||||
|
||||
|
||||
def fetch_blocked_by_issue_ids(issue_id: str, project_id: str, timeout: int = 10) -> list[str]:
|
||||
"""ORCH-026 (B-1): list the Plane issue UUIDs that ``issue_id`` is BLOCKED-BY.
|
||||
|
||||
Reads the Plane issue-relation endpoint and returns the related issue UUIDs
|
||||
declared as ``blocked_by`` (i.e. the predecessors A that this task B waits
|
||||
for). Plane's relation payload shape has varied across versions, so the parse
|
||||
is defensive: it accepts either a grouped object (``{"blocked_by": [...]}``)
|
||||
or a flat list of ``{"relation_type": ..., "related_issue": ...}`` rows, and
|
||||
pulls a uuid from ``related_issue`` / ``issue`` / ``id`` (bare uuid or nested
|
||||
``{"id": ...}``).
|
||||
|
||||
never-raise (AC-G1, self-hosting): a Plane outage / non-2xx / unexpected
|
||||
shape -> ``[]`` (no edge declared), so the ingestion degrades conservatively
|
||||
and the pipeline never stalls on the network.
|
||||
"""
|
||||
if not issue_id or not project_id:
|
||||
return []
|
||||
url = (
|
||||
f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}"
|
||||
f"/issues/{issue_id}/issue-relation/"
|
||||
)
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_blocked_by_issue_ids failed for {issue_id}: {e}")
|
||||
return []
|
||||
|
||||
def _uuid_of(row) -> str | None:
|
||||
if isinstance(row, str):
|
||||
return row
|
||||
if isinstance(row, dict):
|
||||
for key in ("related_issue", "issue", "id"):
|
||||
v = row.get(key)
|
||||
if isinstance(v, dict):
|
||||
v = v.get("id")
|
||||
if v:
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
out: list[str] = []
|
||||
try:
|
||||
rows = []
|
||||
if isinstance(body, dict):
|
||||
# Grouped shape: {"blocked_by": [...], "blocking": [...], ...}
|
||||
if "blocked_by" in body and isinstance(body["blocked_by"], list):
|
||||
rows = body["blocked_by"]
|
||||
else:
|
||||
# Flat shape nested under common envelope keys.
|
||||
rows = body.get("results") or body.get("relations") or []
|
||||
elif isinstance(body, list):
|
||||
rows = body
|
||||
for row in rows:
|
||||
# In the flat shape, keep only blocked_by rows.
|
||||
if isinstance(row, dict) and row.get("relation_type") not in (None, "blocked_by"):
|
||||
continue
|
||||
uid = _uuid_of(row)
|
||||
if uid and uid != issue_id:
|
||||
out.append(uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_blocked_by_issue_ids parse error for {issue_id}: {e}")
|
||||
return []
|
||||
return out
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
|
||||
@@ -673,8 +673,19 @@ def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[b
|
||||
return False, reason
|
||||
|
||||
try:
|
||||
# ORCH-026 (Level A, A-2): proactive pre-merge rebase. When
|
||||
# premerge_rebase_always is on, ALWAYS rebase onto the CURRENT
|
||||
# origin/main under the held lease — even when branch_is_behind_main
|
||||
# says "not behind". The ancestor check can miss a divergence
|
||||
# (squash/force-push history, ORCH-073 phantom-merge class), so an
|
||||
# unconditional rebase is a deterministic anti-phantom: it guarantees
|
||||
# B carries A's code before merge. auto_rebase_onto_main is a cheap
|
||||
# no-op on an already up-to-date branch (rc 0, push up-to-date, CI not
|
||||
# retriggered). Kill-switch off -> 1:1 the ORCH-043 short-circuit
|
||||
# below (rebase only when behind).
|
||||
always = bool(getattr(settings, "premerge_rebase_always", False))
|
||||
# Double-check under the lease: another task may have just merged.
|
||||
if not merge_gate.branch_is_behind_main(repo, branch):
|
||||
if not always and not merge_gate.branch_is_behind_main(repo, branch):
|
||||
logger.info("check_branch_mergeable: %s up-to-date with main", branch)
|
||||
return True, "branch up-to-date with main"
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from .plane_sync import (
|
||||
from .webhooks.plane import handle_status_start, handle_verdict
|
||||
from .notifications import send_telegram, link_for
|
||||
from . import projects
|
||||
from . import task_deps
|
||||
|
||||
logger = logging.getLogger("orchestrator.reconciler")
|
||||
|
||||
@@ -165,6 +166,16 @@ class Reconciler:
|
||||
f"reconciler F-1: task {task.get('id')} "
|
||||
f"(stage={task.get('stage')}) failed: {e}"
|
||||
)
|
||||
# ORCH-026 (B-3) backstop: surface ANY dependency deadlock in the declared
|
||||
# graph, even one whose tasks are not individually evaluated above (e.g. no
|
||||
# active queued job). One alert per cycle; never-raise.
|
||||
if settings.task_deps_enabled:
|
||||
try:
|
||||
cyc = task_deps.find_any_cycle()
|
||||
if cyc:
|
||||
task_deps.handle_cycle(cyc)
|
||||
except Exception as e: # noqa: BLE001 - never break the sweep
|
||||
logger.error(f"reconciler F-1: cycle backstop failed: {e}")
|
||||
|
||||
def _reconcile_gate_task(self, task: dict) -> None:
|
||||
task_id = task["id"]
|
||||
@@ -194,6 +205,18 @@ class Reconciler:
|
||||
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
|
||||
if self._is_blocked_or_needs_input(task):
|
||||
return
|
||||
# ORCH-026 Guard 3 (B-5): a task blocked by an unfinished declared
|
||||
# dependency is legitimately waiting, NOT stuck -> F-1 must not advance it
|
||||
# past its depends-on (mirrors the Blocked/Needs-Input skip). Local DB,
|
||||
# never-raise (is_task_ready fails OPEN). If the wait is actually a
|
||||
# dependency DEADLOCK (cycle), surface it (Blocked + alert) once.
|
||||
if settings.task_deps_enabled:
|
||||
ready, _waiting = task_deps.is_task_ready(task_id)
|
||||
if not ready:
|
||||
cyc = task_deps.detect_cycle(task_id)
|
||||
if cyc:
|
||||
task_deps.handle_cycle(cyc)
|
||||
return
|
||||
result = advance_if_gate_passed(
|
||||
task_id,
|
||||
stage,
|
||||
|
||||
335
src/task_deps.py
Normal file
335
src/task_deps.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""ORCH-026 (Level B): declarative task-dependency logic.
|
||||
|
||||
Leaf module — pure, unit-testable functions over the additive ``job_deps`` table
|
||||
(see src/db.py / 08-data-requirements.md). It answers two questions the rest of
|
||||
the pipeline asks:
|
||||
|
||||
* "is task B ready to run?" — every declared predecessor A reached
|
||||
``tasks.stage = 'done'`` (``is_task_ready``). The scheduler gate in
|
||||
``db.claim_next_job`` enforces the same predicate in SQL; this Python copy is
|
||||
for the reconciler skip and for naming WHAT a task waits on (visibility).
|
||||
* "is there a dependency deadlock?" — a directed cycle A->B->A (or longer) can
|
||||
never be satisfied, so the tasks in it would wait forever. ``detect_cycle`` /
|
||||
``find_any_cycle`` find one deterministically; ``handle_cycle`` escalates it
|
||||
to Blocked + alert so the deadlock is visible instead of silent.
|
||||
|
||||
never-raise contract (AC-G1, self-hosting safety): EVERY public function
|
||||
degrades conservatively on any error (DB/import) and NEVER propagates an
|
||||
exception into the worker / reconciler / webhook. Readiness fails OPEN
|
||||
(``True``) so a transient DB error cannot wedge the whole queue; cycle detection
|
||||
fails CLOSED-safe (``None`` = "no cycle proven", do not block).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from . import db
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readiness gate (B-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_task_ready(task_id: int) -> tuple[bool, list[str]]:
|
||||
"""Return ``(ready, waiting_on)`` for a task.
|
||||
|
||||
``ready`` is True when the task has no declared dependency whose predecessor
|
||||
is still un-done (``tasks.stage != 'done'``). ``waiting_on`` is the list of
|
||||
predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still blocked
|
||||
by — used for the Telegram waiting-line / Plane visibility.
|
||||
|
||||
never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the
|
||||
scheduler omitting the gate when the DB read fails; a transient error must
|
||||
not wedge an otherwise-claimable task).
|
||||
"""
|
||||
if task_id is None:
|
||||
return True, []
|
||||
try:
|
||||
unfinished = db.get_unfinished_dependencies(task_id)
|
||||
except Exception:
|
||||
return True, []
|
||||
if not unfinished:
|
||||
return True, []
|
||||
waiting_on = [
|
||||
str(d.get("work_item_id") or d.get("id"))
|
||||
for d in unfinished
|
||||
]
|
||||
return False, waiting_on
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle / deadlock detection (B-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_adjacency(edges: list[tuple[int, int]]) -> dict[int, list[int]]:
|
||||
"""Build a ``task_id -> [depends_on_task_id, ...]`` adjacency map.
|
||||
|
||||
Edge direction follows the dependency: an edge (B, A) means "B depends on A",
|
||||
so we traverse from a dependent task towards its predecessors. A cycle in
|
||||
this graph is an unsatisfiable deadlock.
|
||||
"""
|
||||
adj: dict[int, list[int]] = {}
|
||||
for task_id, depends_on in edges:
|
||||
adj.setdefault(task_id, []).append(depends_on)
|
||||
return adj
|
||||
|
||||
|
||||
def _find_cycle_from(start: int, adj: dict[int, list[int]]) -> list[int] | None:
|
||||
"""Iterative DFS from ``start``; return a cycle path if one is reachable.
|
||||
|
||||
Returns the node sequence closing the cycle (e.g. ``[A, B, A]``) or None.
|
||||
Iterative (explicit stack) so a pathological deep graph cannot blow the
|
||||
Python recursion limit — relevant on the shared prod process.
|
||||
"""
|
||||
WHITE, GREY, BLACK = 0, 1, 2
|
||||
color: dict[int, int] = {}
|
||||
parent: dict[int, int] = {}
|
||||
# stack of (node, is_exit): is_exit=True marks the post-visit (color BLACK).
|
||||
stack: list[tuple[int, bool]] = [(start, False)]
|
||||
while stack:
|
||||
node, is_exit = stack.pop()
|
||||
if is_exit:
|
||||
color[node] = BLACK
|
||||
continue
|
||||
if color.get(node, WHITE) != WHITE:
|
||||
continue
|
||||
color[node] = GREY
|
||||
stack.append((node, True))
|
||||
for nxt in adj.get(node, []):
|
||||
c = color.get(nxt, WHITE)
|
||||
if c == GREY:
|
||||
# Back-edge -> cycle. Reconstruct path nxt..node via parent.
|
||||
path = [node]
|
||||
cur = node
|
||||
while cur != nxt and cur in parent:
|
||||
cur = parent[cur]
|
||||
path.append(cur)
|
||||
path.reverse()
|
||||
path.append(nxt)
|
||||
return path
|
||||
if c == WHITE:
|
||||
parent[nxt] = node
|
||||
stack.append((nxt, False))
|
||||
return None
|
||||
|
||||
|
||||
def detect_cycle(task_id: int, edges: list[tuple[int, int]] | None = None) -> list[int] | None:
|
||||
"""Detect a dependency cycle reachable from ``task_id``.
|
||||
|
||||
Returns the cycle path (node sequence, first == last) or None when the graph
|
||||
reachable from ``task_id`` is acyclic. ``edges`` may be injected (unit tests);
|
||||
otherwise the full declared edge set is read from the DB.
|
||||
|
||||
never-raise: any error -> None (do not falsely claim a deadlock on an error).
|
||||
"""
|
||||
if task_id is None:
|
||||
return None
|
||||
try:
|
||||
if edges is None:
|
||||
edges = db.get_dependency_edges()
|
||||
adj = _build_adjacency(edges)
|
||||
return _find_cycle_from(task_id, adj)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_any_cycle(edges: list[tuple[int, int]] | None = None) -> list[int] | None:
|
||||
"""Backstop: detect ANY cycle in the whole declared graph.
|
||||
|
||||
Used by the reconciler tick to surface a deadlock even when no specific task
|
||||
is being evaluated. Returns the first cycle found or None. never-raise -> None.
|
||||
"""
|
||||
try:
|
||||
if edges is None:
|
||||
edges = db.get_dependency_edges()
|
||||
adj = _build_adjacency(edges)
|
||||
for node in list(adj.keys()):
|
||||
cyc = _find_cycle_from(node, adj)
|
||||
if cyc:
|
||||
return cyc
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _work_item_id_for(task_id: int) -> str | None:
|
||||
"""Best-effort ``tasks.work_item_id`` lookup for a task_id (never-raise)."""
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT work_item_id FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return row[0] if row and row[0] else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def handle_cycle(cycle: list[int]) -> bool:
|
||||
"""Escalate a detected dependency cycle: Blocked + alert (B-3, AC-G1).
|
||||
|
||||
For every task in the cycle, sets its Plane issue to Blocked (best-effort)
|
||||
and sends ONE Telegram alert naming the cycle, so a deadlock is visible
|
||||
instead of a silent forever-wait. Does NOT mutate job_deps / stages — the
|
||||
declaration is the human's to fix. never-raise: any notify/Plane error is
|
||||
swallowed; the worker/reconciler never crashes. Returns True if an alert was
|
||||
attempted, False on a no-op / error.
|
||||
"""
|
||||
if not cycle:
|
||||
return False
|
||||
try:
|
||||
# Map task ids -> work-item ids for a human-readable chain.
|
||||
labels: list[str] = []
|
||||
seen: set[int] = set()
|
||||
for tid in cycle:
|
||||
wi = _work_item_id_for(tid)
|
||||
labels.append(wi or f"task#{tid}")
|
||||
if tid not in seen:
|
||||
seen.add(tid)
|
||||
chain = " -> ".join(labels)
|
||||
try:
|
||||
from . import notifications, plane_sync
|
||||
except Exception:
|
||||
return False
|
||||
# Blocked indication on each distinct issue in the cycle.
|
||||
for tid in seen:
|
||||
wi = _work_item_id_for(tid)
|
||||
if wi:
|
||||
try:
|
||||
plane_sync.set_issue_blocked(wi)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
notifications.send_telegram(
|
||||
f"\U0001f6a8 ORCH-026: dependency DEADLOCK detected (cycle): {chain}. "
|
||||
f"Tasks set to Blocked — fix the blocked-by declaration."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.error("ORCH-026: dependency cycle detected: %s", chain)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declaration (B-1) — db.add_dependency + immediate cycle escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def declare_dependency(task_id: int, depends_on_task_id: int) -> bool:
|
||||
"""Declare "task_id (B) blocked-by depends_on_task_id (A)" and check for a cycle.
|
||||
|
||||
Thin wrapper over ``db.add_dependency`` that, after a successful insert, runs
|
||||
``detect_cycle`` from the new dependent — so a freshly-introduced deadlock is
|
||||
surfaced (Blocked + alert) at declaration time (best UX, ADR B-3) rather than
|
||||
only by the reconciler backstop. The edge is NOT rolled back on a cycle (the
|
||||
SQL gate already keeps the cyclic tasks un-claimable; the human fixes the
|
||||
declaration) — we make it VISIBLE. never-raise: any error -> False.
|
||||
|
||||
Returns True iff a NEW edge row was inserted (idempotent re-declaration ->
|
||||
False, matching db.add_dependency).
|
||||
"""
|
||||
try:
|
||||
inserted = db.add_dependency(task_id, depends_on_task_id)
|
||||
# Always check for a cycle (even on a duplicate edge an existing cycle may
|
||||
# now be relevant), but only escalate when one is actually found.
|
||||
cyc = detect_cycle(task_id)
|
||||
if cyc:
|
||||
handle_cycle(cyc)
|
||||
return inserted
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ingest_plane_relations(
|
||||
task_id: int, issue_id: str, project_id: str
|
||||
) -> int:
|
||||
"""B-1 (plane/hybrid source): import Plane ``blocked-by`` relations into job_deps.
|
||||
|
||||
Reads the issue's ``blocked_by`` predecessors from Plane, resolves each to a
|
||||
local ``tasks.id`` (intra-repo only, v1) and declares the edge. A predecessor
|
||||
not yet known locally (no task row) is SKIPPED — the scheduler can only gate
|
||||
on tasks it knows; a re-run after that task is created will pick it up.
|
||||
|
||||
Active ONLY when ``task_deps_source`` is ``plane`` or ``hybrid`` (default
|
||||
``db`` -> no Plane call on the hot creation path). never-raise (self-hosting):
|
||||
any error -> 0 edges, the pipeline start is never blocked by Plane. Returns
|
||||
the number of edges declared.
|
||||
"""
|
||||
source = (getattr(settings, "task_deps_source", "db") or "db").strip().lower()
|
||||
if source not in ("plane", "hybrid"):
|
||||
return 0
|
||||
if not issue_id or not project_id:
|
||||
return 0
|
||||
try:
|
||||
from . import plane_sync
|
||||
blocked_by = plane_sync.fetch_blocked_by_issue_ids(issue_id, project_id)
|
||||
except Exception:
|
||||
return 0
|
||||
declared = 0
|
||||
for pred_issue in blocked_by:
|
||||
try:
|
||||
pred = db.get_task_by_plane_id(str(pred_issue))
|
||||
if not pred:
|
||||
continue
|
||||
if declare_dependency(task_id, pred["id"]):
|
||||
declared += 1
|
||||
except Exception:
|
||||
continue
|
||||
return declared
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability (/queue snapshot, G-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Read-only summary of the dependency subsystem for GET /queue (G-2).
|
||||
|
||||
Returns a dict (NOT a source of truth — pure observability):
|
||||
* ``enabled`` — task_deps_enabled flag;
|
||||
* ``source`` — task_deps_source (db|plane|hybrid);
|
||||
* ``edges`` — number of declared edges;
|
||||
* ``blocked_tasks`` — list of ``{task_id, work_item_id, waiting_on}`` for
|
||||
tasks with at least one un-done predecessor;
|
||||
* ``cycle`` — a detected cycle path (work-item labels) or None.
|
||||
|
||||
never-raise: any error -> a minimal dict with the flags and empty data.
|
||||
"""
|
||||
enabled = bool(getattr(settings, "task_deps_enabled", False))
|
||||
source = getattr(settings, "task_deps_source", "db")
|
||||
try:
|
||||
edges = db.get_dependency_edges()
|
||||
blocked: list[dict] = []
|
||||
for task_id in {e[0] for e in edges}:
|
||||
ready, waiting_on = is_task_ready(task_id)
|
||||
if not ready:
|
||||
blocked.append({
|
||||
"task_id": task_id,
|
||||
"work_item_id": _work_item_id_for(task_id),
|
||||
"waiting_on": waiting_on,
|
||||
})
|
||||
cyc = find_any_cycle(edges)
|
||||
cycle_labels = None
|
||||
if cyc:
|
||||
cycle_labels = [(_work_item_id_for(t) or f"task#{t}") for t in cyc]
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"source": source,
|
||||
"edges": len(edges),
|
||||
"blocked_tasks": blocked,
|
||||
"cycle": cycle_labels,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"source": source,
|
||||
"edges": 0,
|
||||
"blocked_tasks": [],
|
||||
"cycle": None,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
68
tests/test_agent_frontmatter_no_model.py
Normal file
68
tests/test_agent_frontmatter_no_model.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""ORCH-074 (G1): the dead `model:` frontmatter is gone from all 6 agent prompts.
|
||||
|
||||
launcher.py never reads frontmatter `model:` — it was a lying/dead declaration
|
||||
(claude-sonnet-4-6 / claude-opus-4-7) that contradicted the real model resolved
|
||||
from config (ORCH-41). The mine: if someone "fixed" the launcher to read it, every
|
||||
agent would silently fall back to a stale model. G1 removes the line entirely so
|
||||
config (agent_model_*) stays the single source of truth.
|
||||
|
||||
TC-01: no .openclaw/agents/*.md contains a `^model:` line in its frontmatter.
|
||||
TC-02: each frontmatter is still valid YAML and keeps name/description.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import yaml # PyYAML
|
||||
_HAVE_YAML = True
|
||||
except Exception: # pragma: no cover - yaml is a test/runtime dep
|
||||
_HAVE_YAML = False
|
||||
|
||||
_AGENTS = ("analyst", "architect", "developer", "reviewer", "tester", "deployer")
|
||||
|
||||
# tests/ is one level under the repo root; .openclaw/agents lives at the root.
|
||||
_AGENTS_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
".openclaw", "agents",
|
||||
)
|
||||
|
||||
|
||||
def _frontmatter_block(text: str) -> str:
|
||||
"""Return the YAML between the first two '---' fences (the frontmatter)."""
|
||||
lines = text.splitlines()
|
||||
assert lines and lines[0].strip() == "---", "frontmatter must open with '---'"
|
||||
end = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end = i
|
||||
break
|
||||
assert end is not None, "frontmatter must close with a second '---'"
|
||||
return "\n".join(lines[1:end])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", _AGENTS)
|
||||
def test_no_model_line_in_frontmatter(agent):
|
||||
"""TC-01: no agent prompt declares a `model:` key in its frontmatter."""
|
||||
path = os.path.join(_AGENTS_DIR, f"{agent}.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
block = _frontmatter_block(f.read())
|
||||
for line in block.splitlines():
|
||||
assert not line.lstrip().startswith("model:"), (
|
||||
f"{agent}.md still declares a frontmatter 'model:' line: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", _AGENTS)
|
||||
def test_frontmatter_still_valid_yaml_with_keys(agent):
|
||||
"""TC-02: frontmatter parses as YAML and keeps name/description (no model)."""
|
||||
path = os.path.join(_AGENTS_DIR, f"{agent}.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
block = _frontmatter_block(f.read())
|
||||
if not _HAVE_YAML:
|
||||
pytest.skip("PyYAML not available")
|
||||
data = yaml.safe_load(block)
|
||||
assert isinstance(data, dict), f"{agent}.md frontmatter is not a YAML mapping"
|
||||
assert data.get("name") == agent
|
||||
assert data.get("description"), f"{agent}.md lost its description"
|
||||
assert "model" not in data, f"{agent}.md frontmatter still has a model key"
|
||||
@@ -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)
|
||||
|
||||
118
tests/test_orch026_conditionality.py
Normal file
118
tests/test_orch026_conditionality.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""ORCH-026 conditionality / self-hosting safety (TC-A06, TC-A07).
|
||||
|
||||
TC-A06 kill-switch / out-of-scope: with the flag off (or for a repo outside the
|
||||
merge-gate scope) the merge path behaves 1:1 as before ORCH-026 — no-op.
|
||||
TC-A07 self-hosting safety: the new Level-A logic never pushes to main; the only
|
||||
force op stays --force-with-lease on the task branch; STAGE_TRANSITIONS
|
||||
and the QG_CHECKS registry are unchanged.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_cond.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
from src.qg import checks # noqa: E402
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A06
|
||||
def test_out_of_scope_repo_is_noop_even_with_flag_on(monkeypatch):
|
||||
"""A repo outside merge_gate scope -> N/A pass, regardless of premerge flag."""
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_repos", "orchestrator", raising=False)
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
|
||||
# enduro-trails is NOT in the scope -> no lease, no rebase, just N/A.
|
||||
called = {"acquire": 0, "rebase": 0}
|
||||
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
|
||||
lambda *a, **k: (called.__setitem__("acquire", called["acquire"] + 1), (True, "x"))[1],
|
||||
raising=False)
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main",
|
||||
lambda *a, **k: (called.__setitem__("rebase", called["rebase"] + 1), (True, "x"))[1],
|
||||
raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("enduro-trails", "ET-1", "feature/e")
|
||||
assert ok is True
|
||||
assert "N/A" in reason
|
||||
assert called["acquire"] == 0 and called["rebase"] == 0
|
||||
|
||||
|
||||
def test_task_deps_kill_switch_omits_gate(monkeypatch):
|
||||
"""task_deps_enabled=False -> claim_next_job query is the ORCH-1 query (no gate)."""
|
||||
import src.db as db
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", False, raising=False)
|
||||
# Inspect the SQL the claim builds by stubbing the connection.
|
||||
captured = {}
|
||||
|
||||
class _FakeConn:
|
||||
def execute(self, sql, *a):
|
||||
captured.setdefault("sql", sql)
|
||||
|
||||
class _R:
|
||||
def fetchone(self_inner):
|
||||
return None
|
||||
return _R()
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
|
||||
db.claim_next_job()
|
||||
assert "NOT EXISTS" not in captured["sql"], "gate must be omitted when disabled"
|
||||
|
||||
|
||||
def test_task_deps_enabled_adds_gate(monkeypatch):
|
||||
import src.db as db
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
captured = {}
|
||||
|
||||
class _FakeConn:
|
||||
def execute(self, sql, *a):
|
||||
captured.setdefault("sql", sql)
|
||||
|
||||
class _R:
|
||||
def fetchone(self_inner):
|
||||
return None
|
||||
return _R()
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
|
||||
db.claim_next_job()
|
||||
assert "NOT EXISTS" in captured["sql"], "gate must be present when enabled"
|
||||
assert "job_deps" in captured["sql"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A07
|
||||
def test_stage_transitions_unchanged():
|
||||
"""ORCH-026 must not touch the state machine (AC-A5)."""
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
# The canonical happy-path edges must still exist exactly.
|
||||
assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy"
|
||||
assert STAGE_TRANSITIONS["deploy"]["next"] == "done"
|
||||
assert STAGE_TRANSITIONS["development"]["next"] == "review"
|
||||
|
||||
|
||||
def test_qg_registry_has_no_new_dep_gate():
|
||||
"""The dependency gate is врезка in claim_next_job, NOT a registered QG."""
|
||||
from src.qg.checks import QG_CHECKS
|
||||
joined = " ".join(QG_CHECKS.keys())
|
||||
assert "task_dep" not in joined and "dependency" not in joined
|
||||
|
||||
|
||||
def test_premerge_only_force_with_lease_on_branch():
|
||||
"""auto_rebase_onto_main never pushes to main; force is --force-with-lease only."""
|
||||
import inspect
|
||||
src = inspect.getsource(merge_gate.auto_rebase_onto_main)
|
||||
assert "--force-with-lease" in src
|
||||
# No raw 'push origin main' / force-push to main in the rebase path.
|
||||
assert "push origin main" not in src
|
||||
assert "--force " not in src # plain --force (not -with-lease) is forbidden
|
||||
136
tests/test_orch026_dep_cycles.py
Normal file
136
tests/test_orch026_dep_cycles.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""ORCH-026 Level B — dependency cycle / deadlock detection (TC-B03, TC-B04).
|
||||
|
||||
TC-B03 detect_cycle is deterministic: A->B->A (and longer) is detected; an
|
||||
acyclic graph yields None. Pure function (edges injected).
|
||||
TC-B04 a detected cycle escalates: set_issue_blocked + a Telegram alert, with
|
||||
no worker crash and no blocking of other tasks (never-raise).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_cycles.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "cycles.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B03
|
||||
def test_detect_two_node_cycle():
|
||||
# edge (B, A) means "B depends on A"; 1->2 and 2->1 is a 2-cycle.
|
||||
edges = [(1, 2), (2, 1)]
|
||||
cyc = task_deps.detect_cycle(1, edges=edges)
|
||||
assert cyc is not None
|
||||
assert cyc[0] == cyc[-1] # closed cycle
|
||||
assert set(cyc) == {1, 2}
|
||||
|
||||
|
||||
def test_detect_longer_cycle():
|
||||
edges = [(1, 2), (2, 3), (3, 1)]
|
||||
cyc = task_deps.detect_cycle(1, edges=edges)
|
||||
assert cyc is not None
|
||||
assert set(cyc) >= {1, 2, 3}
|
||||
|
||||
|
||||
def test_acyclic_graph_has_no_cycle():
|
||||
edges = [(1, 2), (2, 3), (1, 3)] # DAG
|
||||
assert task_deps.detect_cycle(1, edges=edges) is None
|
||||
assert task_deps.find_any_cycle(edges=edges) is None
|
||||
|
||||
|
||||
def test_find_any_cycle_scans_whole_graph():
|
||||
# A disconnected cycle 10<->11 not reachable from node 1.
|
||||
edges = [(1, 2), (10, 11), (11, 10)]
|
||||
assert task_deps.detect_cycle(1, edges=edges) is None
|
||||
cyc = task_deps.find_any_cycle(edges=edges)
|
||||
assert cyc is not None
|
||||
assert set(cyc) == {10, 11}
|
||||
|
||||
|
||||
def test_detect_cycle_never_raises_on_garbage():
|
||||
assert task_deps.detect_cycle(None) is None
|
||||
# Malformed edge list -> swallowed -> None.
|
||||
assert task_deps.detect_cycle(1, edges="not-a-list") is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B04
|
||||
def _make_task(work_item_id, stage="development"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def test_handle_cycle_blocks_and_alerts(monkeypatch):
|
||||
a = _make_task("ORCH-60")
|
||||
b = _make_task("ORCH-61")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a) # cycle a<->b
|
||||
|
||||
blocked = []
|
||||
alerts = []
|
||||
import src.plane_sync as plane_sync
|
||||
import src.notifications as notifications
|
||||
monkeypatch.setattr(plane_sync, "set_issue_blocked",
|
||||
lambda wi, *a, **k: blocked.append(wi), raising=False)
|
||||
monkeypatch.setattr(notifications, "send_telegram",
|
||||
lambda text, *a, **k: alerts.append(text), raising=False)
|
||||
|
||||
cyc = task_deps.detect_cycle(a)
|
||||
assert cyc is not None
|
||||
ok = task_deps.handle_cycle(cyc)
|
||||
assert ok is True
|
||||
assert set(blocked) == {"ORCH-60", "ORCH-61"}
|
||||
assert len(alerts) == 1
|
||||
assert "ORCH-60" in alerts[0] and "ORCH-61" in alerts[0]
|
||||
|
||||
|
||||
def test_handle_cycle_never_raises_when_notify_fails(monkeypatch):
|
||||
a = _make_task("ORCH-70")
|
||||
b = _make_task("ORCH-71")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a)
|
||||
import src.plane_sync as plane_sync
|
||||
import src.notifications as notifications
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("plane down")
|
||||
|
||||
monkeypatch.setattr(plane_sync, "set_issue_blocked", _boom, raising=False)
|
||||
monkeypatch.setattr(notifications, "send_telegram", _boom, raising=False)
|
||||
cyc = task_deps.detect_cycle(a)
|
||||
# Must not propagate the exception (AC-G1).
|
||||
assert task_deps.handle_cycle(cyc) in (True, False)
|
||||
|
||||
|
||||
def test_declare_dependency_escalates_cycle(monkeypatch):
|
||||
"""declare_dependency surfaces a freshly-introduced cycle at declaration."""
|
||||
a = _make_task("ORCH-80")
|
||||
b = _make_task("ORCH-81")
|
||||
handled = []
|
||||
monkeypatch.setattr(task_deps, "handle_cycle",
|
||||
lambda cyc: handled.append(cyc), raising=False)
|
||||
assert task_deps.declare_dependency(a, b) is True
|
||||
assert handled == [] # no cycle yet
|
||||
# Closing the loop -> handle_cycle invoked.
|
||||
assert task_deps.declare_dependency(b, a) is True
|
||||
assert len(handled) == 1
|
||||
79
tests/test_orch026_dep_visibility.py
Normal file
79
tests/test_orch026_dep_visibility.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""ORCH-026 Level B — blocked-task visibility (TC-B06).
|
||||
|
||||
A dep-blocked task surfaces a waiting-line ("⏳ ждёт ORCH-NNN") in its single
|
||||
Telegram tracker card; the "one card per task" invariant is preserved (the line
|
||||
is added to the SAME render, not a new message). Render is never broken by the
|
||||
dependency lookup (never-raise).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_visibility.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import notifications # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "vis.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="development"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}",
|
||||
stage, f"title {work_item_id}"),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def test_blocked_task_shows_waiting_line():
|
||||
a = _make_task("ORCH-90", stage="development")
|
||||
b = _make_task("ORCH-91", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
text = notifications.render_task_tracker(b)
|
||||
assert "ждёт" in text
|
||||
assert "ORCH-90" in text
|
||||
|
||||
|
||||
def test_ready_task_has_no_waiting_line():
|
||||
a = _make_task("ORCH-92", stage="done")
|
||||
b = _make_task("ORCH-93", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
text = notifications.render_task_tracker(b)
|
||||
assert "ждёт" not in text
|
||||
|
||||
|
||||
def test_done_task_has_no_waiting_line():
|
||||
a = _make_task("ORCH-94", stage="development")
|
||||
b = _make_task("ORCH-95", stage="done")
|
||||
db.add_dependency(b, a)
|
||||
text = notifications.render_task_tracker(b)
|
||||
# A done task is terminal -> the waiting-line branch is skipped entirely.
|
||||
assert "ждёт" not in text
|
||||
|
||||
|
||||
def test_render_never_raises_on_dep_error(monkeypatch):
|
||||
b = _make_task("ORCH-96", stage="development")
|
||||
from src import task_deps
|
||||
monkeypatch.setattr(task_deps, "is_task_ready",
|
||||
lambda tid: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
raising=False)
|
||||
# Must still produce a card (no crash).
|
||||
text = notifications.render_task_tracker(b)
|
||||
assert "ORCH-96" in text
|
||||
124
tests/test_orch026_deps_integration.py
Normal file
124
tests/test_orch026_deps_integration.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""ORCH-026 Level B — declarative dependencies integration (TC-B08).
|
||||
|
||||
End-to-end (DB level): B declared blocked-by A; queued B does not start until A
|
||||
is 'done'; after A->done the worker can claim B. Also covers the plane/hybrid
|
||||
ingestion path: Plane `blocked-by` relations are resolved to local task ids and
|
||||
written into job_deps (the scheduler then reads only the DB).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_depsint.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "depsint.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="development", plane_id=None):
|
||||
conn = get_db()
|
||||
pid = plane_id or work_item_id
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(pid, work_item_id, "orchestrator", f"feature/{work_item_id}", stage, pid),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _set_stage(task_id, stage):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_b_waits_for_a_then_runs():
|
||||
a = _make_task("ORCH-200", stage="development")
|
||||
b = _make_task("ORCH-201", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
|
||||
# While A is in flight, B is not claimable.
|
||||
assert claim_next_job() is None
|
||||
ready, waiting = task_deps.is_task_ready(b)
|
||||
assert ready is False and "ORCH-200" in waiting
|
||||
|
||||
# A advances through to done.
|
||||
_set_stage(a, "review")
|
||||
assert claim_next_job() is None # still not terminal
|
||||
_set_stage(a, "done")
|
||||
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
def test_multiple_predecessors_all_must_be_done():
|
||||
a1 = _make_task("ORCH-210", stage="development")
|
||||
a2 = _make_task("ORCH-211", stage="development")
|
||||
b = _make_task("ORCH-212", stage="development")
|
||||
db.add_dependency(b, a1)
|
||||
db.add_dependency(b, a2)
|
||||
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b)
|
||||
|
||||
_set_stage(a1, "done")
|
||||
assert claim_next_job() is None, "still blocked by a2"
|
||||
_set_stage(a2, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# ---- plane/hybrid ingestion path (TC-B01) ---------------------------------
|
||||
def test_ingest_plane_relations_writes_db(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "hybrid", raising=False)
|
||||
a = _make_task("ORCH-220", stage="development", plane_id="plane-uuid-A")
|
||||
b = _make_task("ORCH-221", stage="development", plane_id="plane-uuid-B")
|
||||
|
||||
import src.plane_sync as plane_sync
|
||||
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
|
||||
lambda issue_id, project_id, **k: ["plane-uuid-A"],
|
||||
raising=False)
|
||||
n = task_deps.ingest_plane_relations(b, "plane-uuid-B", "proj-1")
|
||||
assert n == 1
|
||||
assert db.get_dependencies(b) == [a]
|
||||
|
||||
|
||||
def test_ingest_noop_when_source_db(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
|
||||
b = _make_task("ORCH-230", stage="development", plane_id="plane-uuid-Z")
|
||||
import src.plane_sync as plane_sync
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids",
|
||||
lambda *a, **k: called.__setitem__("n", called["n"] + 1) or [],
|
||||
raising=False)
|
||||
n = task_deps.ingest_plane_relations(b, "plane-uuid-Z", "proj-1")
|
||||
assert n == 0
|
||||
assert called["n"] == 0, "default db source must not call Plane"
|
||||
|
||||
|
||||
def test_ingest_never_raises_on_plane_outage(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "plane", raising=False)
|
||||
b = _make_task("ORCH-240", stage="development", plane_id="plane-uuid-Y")
|
||||
import src.plane_sync as plane_sync
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("plane down")
|
||||
|
||||
monkeypatch.setattr(plane_sync, "fetch_blocked_by_issue_ids", _boom, raising=False)
|
||||
assert task_deps.ingest_plane_relations(b, "plane-uuid-Y", "proj-1") == 0
|
||||
95
tests/test_orch026_merge_serialize.py
Normal file
95
tests/test_orch026_merge_serialize.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""ORCH-026 Level A serialization (TC-A02..A05).
|
||||
|
||||
The merge-lease window (ORCH-043/065) is what serialises "merge -> main-updated"
|
||||
per repo; ORCH-026 reuses it unchanged. These tests confirm the properties the
|
||||
ADR relies on:
|
||||
|
||||
TC-A02 extended window: while A holds the lease, B of the SAME repo gets
|
||||
"merge-lock busy" -> defer (not rollback); holder-aware release does
|
||||
NOT delete A's lease.
|
||||
TC-A03 strict per-repo: an orchestrator lease never blocks an enduro-trails
|
||||
acquire (both claimable in parallel).
|
||||
TC-A04 restart-safe + proactive reclaim: a dead holder's lease is reclaimed
|
||||
(reclaim_stale_lease) so the pipeline never wedges forever.
|
||||
TC-A05 anti-livelock defer budget: merge_defer_max_attempts is bounded and
|
||||
positive -> exhaustion escalates instead of looping forever.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serialize.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leases_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True, raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A02
|
||||
def test_second_task_same_repo_defers_not_rollback(leases_dir):
|
||||
okA, reasonA = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
assert okA is True
|
||||
|
||||
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is False
|
||||
assert reasonB == "merge-lock busy" # -> caller DEFERS, never a rollback signal
|
||||
|
||||
|
||||
def test_holder_aware_release_keeps_foreign_lease(leases_dir):
|
||||
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
# A delayed release from B (which never held it) must NOT delete A's lease.
|
||||
merge_gate.release_merge_lease("orchestrator", "feature/B")
|
||||
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is False and reasonB == "merge-lock busy"
|
||||
# A's own release frees it.
|
||||
merge_gate.release_merge_lease("orchestrator", "feature/A")
|
||||
okB2, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB2 is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A03
|
||||
def test_serialization_is_strictly_per_repo(leases_dir):
|
||||
okA, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
okET, _ = merge_gate.acquire_merge_lease("enduro-trails", "feature/E", "ET-1")
|
||||
assert okA is True
|
||||
assert okET is True, "a different repo must be claimable in parallel (AC-A3)"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A04
|
||||
def test_dead_holder_lease_is_reclaimed(leases_dir, monkeypatch):
|
||||
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
# Holder pid is THIS process; simulate it being dead.
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False, raising=False)
|
||||
reclaimed = merge_gate.reclaim_stale_lease("orchestrator")
|
||||
assert reclaimed is True
|
||||
# After reclaim B can acquire -> pipeline does not wedge forever.
|
||||
okB, _ = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is True
|
||||
|
||||
|
||||
def test_stale_lease_age_reclaimed_on_acquire(leases_dir, monkeypatch):
|
||||
# A very short timeout makes the existing lease look stale on B's acquire.
|
||||
merge_gate.acquire_merge_lease("orchestrator", "feature/A", "ORCH-1")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 0, raising=False)
|
||||
okB, reasonB = merge_gate.acquire_merge_lease("orchestrator", "feature/B", "ORCH-2")
|
||||
assert okB is True
|
||||
assert "reclaimed" in reasonB
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-A05
|
||||
def test_defer_budget_is_bounded(monkeypatch):
|
||||
"""The defer budget is a positive finite int -> exhaustion escalates (AC-A6)."""
|
||||
from src.config import settings
|
||||
assert isinstance(settings.merge_defer_max_attempts, int)
|
||||
assert settings.merge_defer_max_attempts > 0
|
||||
assert settings.merge_defer_delay_s > 0
|
||||
83
tests/test_orch026_migration.py
Normal file
83
tests/test_orch026_migration.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""ORCH-026 — additive job_deps migration (TC-G01, AC-G4).
|
||||
|
||||
The migration must be additive (CREATE TABLE/INDEX IF NOT EXISTS), idempotent,
|
||||
and safe on a pre-existing DB with data: existing columns of jobs/tasks/
|
||||
agent_runs/events are untouched.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_migration.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbfile(tmp_path, monkeypatch):
|
||||
f = tmp_path / "mig.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(f))
|
||||
return f
|
||||
|
||||
|
||||
def _columns(conn, table):
|
||||
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
|
||||
|
||||
def test_job_deps_table_created(dbfile):
|
||||
init_db()
|
||||
conn = get_db()
|
||||
cols = _columns(conn, "job_deps")
|
||||
conn.close()
|
||||
assert set(cols) == {"task_id", "depends_on_task_id", "created_at"}
|
||||
|
||||
|
||||
def test_job_deps_indices_created(dbfile):
|
||||
init_db()
|
||||
conn = get_db()
|
||||
idx = {r[1] for r in conn.execute("PRAGMA index_list(job_deps)").fetchall()}
|
||||
conn.close()
|
||||
assert "idx_job_deps_task" in idx
|
||||
assert "idx_job_deps_depends" in idx
|
||||
|
||||
|
||||
def test_primary_key_idempotent_insert(dbfile):
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
|
||||
conn.execute("INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) VALUES (1, 2)")
|
||||
conn.commit()
|
||||
n = conn.execute("SELECT COUNT(*) FROM job_deps").fetchone()[0]
|
||||
conn.close()
|
||||
assert n == 1, "PK (task_id, depends_on_task_id) prevents dup rows"
|
||||
|
||||
|
||||
def test_migration_idempotent_and_preserves_data(dbfile):
|
||||
# First init + seed legacy data.
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES ('ET-1','ET-1','enduro-trails','feature/x','development')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, status) VALUES ('developer','enduro-trails','queued')"
|
||||
)
|
||||
conn.commit()
|
||||
tasks_cols_before = _columns(conn, "tasks")
|
||||
jobs_cols_before = _columns(conn, "jobs")
|
||||
conn.close()
|
||||
|
||||
# Re-run init_db (simulates a restart on a live DB) -> must be a no-op.
|
||||
init_db()
|
||||
conn = get_db()
|
||||
assert _columns(conn, "tasks") == tasks_cols_before, "tasks columns unchanged"
|
||||
assert _columns(conn, "jobs") == jobs_cols_before, "jobs columns unchanged"
|
||||
# Legacy data survives.
|
||||
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
|
||||
conn.close()
|
||||
82
tests/test_orch026_premerge_rebase.py
Normal file
82
tests/test_orch026_premerge_rebase.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""ORCH-026 Level A (TC-A01): proactive pre-merge rebase.
|
||||
|
||||
check_branch_mergeable must ALWAYS rebase the task branch onto the current
|
||||
origin/main under the held merge-lease when ``premerge_rebase_always`` is on —
|
||||
even when ``branch_is_behind_main`` would short-circuit (no conflict, formally
|
||||
not behind). With the flag OFF the ORCH-043 short-circuit is restored 1:1.
|
||||
|
||||
These are pure unit tests: every merge_gate primitive is monkeypatched, so no
|
||||
git/network is touched — we assert the CONTROL FLOW (was auto_rebase_onto_main
|
||||
called?) and the verdict.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_premerge.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
from src.qg import checks # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_gate(monkeypatch):
|
||||
"""Patch merge_gate primitives; record whether auto_rebase ran."""
|
||||
calls = {"rebase": 0, "retest": 0, "released": 0, "behind_checked": 0}
|
||||
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
|
||||
|
||||
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
|
||||
lambda *a, **k: (True, "lease acquired"), raising=False)
|
||||
|
||||
def _behind(repo, branch):
|
||||
calls["behind_checked"] += 1
|
||||
return False # NOT behind -> ORCH-043 would short-circuit
|
||||
|
||||
def _rebase(repo, branch):
|
||||
calls["rebase"] += 1
|
||||
return True, "rebased (noop)"
|
||||
|
||||
def _retest(repo, branch):
|
||||
calls["retest"] += 1
|
||||
return True, "green"
|
||||
|
||||
def _release(repo, branch=None):
|
||||
calls["released"] += 1
|
||||
|
||||
monkeypatch.setattr(merge_gate, "branch_is_behind_main", _behind, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "retest_branch", _retest, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "release_merge_lease", _release, raising=False)
|
||||
return calls
|
||||
|
||||
|
||||
def test_always_rebases_even_when_not_behind(patched_gate, monkeypatch):
|
||||
"""premerge_rebase_always=True -> auto_rebase_onto_main ALWAYS called (AC-A2)."""
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
|
||||
assert ok is True
|
||||
assert patched_gate["rebase"] == 1, "rebase must run even when not behind"
|
||||
assert patched_gate["retest"] == 1, "re-test must run after the proactive rebase"
|
||||
|
||||
|
||||
def test_flag_off_short_circuits_like_orch043(patched_gate, monkeypatch):
|
||||
"""premerge_rebase_always=False -> not-behind short-circuit, no rebase (AC-A7)."""
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", False, raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
|
||||
assert ok is True
|
||||
assert reason == "branch up-to-date with main"
|
||||
assert patched_gate["rebase"] == 0, "must NOT rebase when not behind and flag off"
|
||||
|
||||
|
||||
def test_disabled_gate_is_noop(monkeypatch):
|
||||
"""merge_gate_enabled=False -> pass-through, no lease/rebase at all (AC-G2)."""
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", False, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
ok, reason = checks.check_branch_mergeable("orchestrator", "ORCH-026", "feature/x")
|
||||
assert ok is True
|
||||
assert "disabled" in reason
|
||||
90
tests/test_orch026_queue_observability.py
Normal file
90
tests/test_orch026_queue_observability.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""ORCH-026 — /queue task_deps observability (TC-G02, G-2).
|
||||
|
||||
task_deps.snapshot() is a read-only summary (NOT a source of truth) exposing the
|
||||
declared edges, blocked tasks and any detected cycle. It must never raise.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_queue_obs.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "obs.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
monkeypatch.setattr(db.settings, "task_deps_source", "db", raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="development"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def test_snapshot_shape_empty():
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["enabled"] is True
|
||||
assert snap["source"] == "db"
|
||||
assert snap["edges"] == 0
|
||||
assert snap["blocked_tasks"] == []
|
||||
assert snap["cycle"] is None
|
||||
|
||||
|
||||
def test_snapshot_reports_blocked_task():
|
||||
a = _make_task("ORCH-100", stage="development")
|
||||
b = _make_task("ORCH-101", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["edges"] == 1
|
||||
assert len(snap["blocked_tasks"]) == 1
|
||||
bt = snap["blocked_tasks"][0]
|
||||
assert bt["work_item_id"] == "ORCH-101"
|
||||
assert "ORCH-100" in bt["waiting_on"]
|
||||
assert snap["cycle"] is None
|
||||
|
||||
|
||||
def test_snapshot_reports_cycle():
|
||||
a = _make_task("ORCH-102")
|
||||
b = _make_task("ORCH-103")
|
||||
db.add_dependency(a, b)
|
||||
db.add_dependency(b, a)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["cycle"] is not None
|
||||
assert "ORCH-102" in snap["cycle"] or "ORCH-103" in snap["cycle"]
|
||||
|
||||
|
||||
def test_snapshot_never_raises(monkeypatch):
|
||||
monkeypatch.setattr(db, "get_dependency_edges",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
raising=False)
|
||||
snap = task_deps.snapshot()
|
||||
assert snap["edges"] == 0
|
||||
assert snap["blocked_tasks"] == []
|
||||
|
||||
|
||||
def test_queue_endpoint_includes_task_deps(monkeypatch):
|
||||
"""GET /queue payload carries the task_deps block (read-only)."""
|
||||
import asyncio
|
||||
from src import main
|
||||
payload = asyncio.run(main.queue())
|
||||
assert "task_deps" in payload
|
||||
assert "enabled" in payload["task_deps"]
|
||||
65
tests/test_orch026_serialize_integration.py
Normal file
65
tests/test_orch026_serialize_integration.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""ORCH-026 Level A — serialization integration (TC-A08).
|
||||
|
||||
Scenario (no network, lease + gate level): two tasks of the SAME repo race for
|
||||
the merge edge. While A holds the merge-lease (the merge->main-updated window),
|
||||
B's check_branch_mergeable returns "merge-lock busy" -> the engine DEFERS B (it
|
||||
does NOT roll back). After A releases (A reached main / done), B acquires, is
|
||||
proactively rebased onto the now-current main (carrying A's code) and merges.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_serint.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import merge_gate # noqa: E402
|
||||
from src.qg import checks # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(checks.settings, "merge_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
|
||||
# Make the git/test primitives deterministic no-ops; A's rebase is a no-op,
|
||||
# B's rebase is the real "catch up to A's code".
|
||||
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False, raising=False)
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", lambda r, b: (True, "ok"), raising=False)
|
||||
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "green"), raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_serialized_merge_window(env, monkeypatch):
|
||||
repo = "orchestrator"
|
||||
# A reaches the merge edge first: gate passes and HOLDS the lease.
|
||||
okA, reasonA = checks.check_branch_mergeable(repo, "ORCH-1", "feature/A")
|
||||
assert okA is True
|
||||
# Lease is held by A.
|
||||
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
|
||||
|
||||
# B reaches the merge edge while A still holds the window -> busy -> DEFER.
|
||||
okB, reasonB = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
|
||||
assert okB is False
|
||||
assert reasonB == "merge-lock busy" # NOT a rollback; engine re-queues via available_at
|
||||
# B's defer must NOT have stolen / cleared A's lease.
|
||||
assert merge_gate._read_lease(merge_gate._lease_path(repo))["branch"] == "feature/A"
|
||||
|
||||
# A completes (PR merged / deploy->done) -> lease released.
|
||||
merge_gate.release_merge_lease(repo, "feature/A")
|
||||
|
||||
# B retries: now acquires, is proactively rebased onto current main, merges.
|
||||
rebased = {"called": 0}
|
||||
|
||||
def _rebase(r, b):
|
||||
rebased["called"] += 1
|
||||
return True, "rebased onto A"
|
||||
|
||||
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main", _rebase, raising=False)
|
||||
okB2, reasonB2 = checks.check_branch_mergeable(repo, "ORCH-2", "feature/B")
|
||||
assert okB2 is True
|
||||
assert rebased["called"] == 1, "B must be proactively rebased onto the fresh main (A's code)"
|
||||
157
tests/test_orch026_task_deps.py
Normal file
157
tests/test_orch026_task_deps.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""ORCH-026 Level B — declarative task dependencies (TC-B01/B02/B05/B07).
|
||||
|
||||
Real SQLite (tmp db). We drive tasks + job_deps directly and assert:
|
||||
TC-B01 add_dependency declares an edge; get_dependencies resolves it; a
|
||||
self-edge is rejected; never-raise on a bad input.
|
||||
TC-B02 is_task_ready: a task with an un-done predecessor is NOT ready; when
|
||||
every predecessor reaches 'done' it becomes ready.
|
||||
TC-B05 claim_next_job does NOT claim a dep-blocked job (no slot taken); once
|
||||
the predecessor is 'done' the job becomes claimable.
|
||||
TC-B07 reconciler skip helper: is_task_ready=False is honoured (the gate task
|
||||
is left waiting).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch026_task_deps.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import task_deps # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "deps.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(stage="development", work_item_id="ORCH-1", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _set_stage(task_id, stage):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage=? WHERE id=?", (stage, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B01
|
||||
def test_add_dependency_declares_and_resolves():
|
||||
a = _make_task(work_item_id="ORCH-10", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-11", stage="development")
|
||||
assert db.add_dependency(b, a) is True
|
||||
assert db.get_dependencies(b) == [a]
|
||||
# Idempotent: re-declaring the same edge is a no-op.
|
||||
assert db.add_dependency(b, a) is False
|
||||
|
||||
|
||||
def test_self_edge_rejected():
|
||||
a = _make_task(work_item_id="ORCH-12")
|
||||
assert db.add_dependency(a, a) is False
|
||||
assert db.get_dependencies(a) == []
|
||||
|
||||
|
||||
def test_add_dependency_never_raises_on_bad_input():
|
||||
assert db.add_dependency(None, 1) is False
|
||||
assert db.add_dependency(1, None) is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B02
|
||||
def test_is_task_ready_blocked_then_ready():
|
||||
a = _make_task(work_item_id="ORCH-20", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-21", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
ready, waiting = task_deps.is_task_ready(b)
|
||||
assert ready is False
|
||||
assert "ORCH-20" in waiting
|
||||
|
||||
_set_stage(a, "done")
|
||||
ready2, waiting2 = task_deps.is_task_ready(b)
|
||||
assert ready2 is True
|
||||
assert waiting2 == []
|
||||
|
||||
|
||||
def test_is_task_ready_no_deps_is_ready():
|
||||
a = _make_task(work_item_id="ORCH-22")
|
||||
ready, waiting = task_deps.is_task_ready(a)
|
||||
assert ready is True and waiting == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B05
|
||||
def test_claim_skips_dep_blocked_job():
|
||||
a = _make_task(work_item_id="ORCH-30", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-31", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
job_b = enqueue_job("developer", "orchestrator", "do B", task_id=b)
|
||||
# B is blocked by un-done A -> claim must NOT pick it (no slot taken).
|
||||
claimed = claim_next_job()
|
||||
assert claimed is None, "dep-blocked job must not be claimed"
|
||||
|
||||
# A finishes -> B becomes claimable.
|
||||
_set_stage(a, "done")
|
||||
claimed2 = claim_next_job()
|
||||
assert claimed2 is not None
|
||||
assert claimed2["id"] == job_b
|
||||
|
||||
|
||||
def test_claim_prefers_unblocked_job_over_blocked():
|
||||
a = _make_task(work_item_id="ORCH-40", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-41", stage="development")
|
||||
c = _make_task(work_item_id="ORCH-42", stage="development")
|
||||
db.add_dependency(b, a) # b blocked by a
|
||||
|
||||
job_b = enqueue_job("developer", "orchestrator", "B", task_id=b) # older id
|
||||
job_c = enqueue_job("developer", "orchestrator", "C", task_id=c) # not blocked
|
||||
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None
|
||||
assert claimed["id"] == job_c, "blocked B skipped, unblocked C claimed"
|
||||
assert job_b # referenced
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- TC-B07
|
||||
def test_reconciler_skip_helper_honours_block(monkeypatch):
|
||||
"""The reconciler reads is_task_ready; a not-ready task must be skipped."""
|
||||
from src import reconciler as rec
|
||||
a = _make_task(work_item_id="ORCH-50", stage="development")
|
||||
b = _make_task(work_item_id="ORCH-51", stage="development")
|
||||
db.add_dependency(b, a)
|
||||
|
||||
advanced = {"called": False}
|
||||
monkeypatch.setattr(rec, "advance_if_gate_passed",
|
||||
lambda *a, **k: advanced.__setitem__("called", True),
|
||||
raising=False)
|
||||
monkeypatch.setattr(rec, "has_active_job_for_task", lambda tid: False, raising=False)
|
||||
monkeypatch.setattr(rec, "developer_retry_count", lambda tid: 0, raising=False)
|
||||
monkeypatch.setattr(rec.settings, "task_deps_enabled", True, raising=False)
|
||||
monkeypatch.setattr(rec.settings, "reconcile_enabled", True, raising=False)
|
||||
monkeypatch.setattr(rec.settings, "reconcile_grace_default_s", 0, raising=False)
|
||||
|
||||
r = rec.Reconciler()
|
||||
# Bypass Guard 2 (networked) so we isolate Guard 3.
|
||||
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda task: False)
|
||||
|
||||
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
|
||||
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}
|
||||
r._reconcile_gate_task(task_row)
|
||||
assert advanced["called"] is False, "dep-blocked task must not be advanced (B-5)"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -26,13 +26,22 @@ from src.projects import ProjectConfig, reload_projects
|
||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
|
||||
|
||||
# ORCH-081/ORCH-52h: canonical effort per role (developer upgraded high -> xhigh).
|
||||
CANON_EFFORT = {
|
||||
"analyst": "high",
|
||||
"architect": "high",
|
||||
"developer": "xhigh",
|
||||
"reviewer": "high",
|
||||
"tester": "medium",
|
||||
"deployer": "medium",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "high")
|
||||
for a in ("analyst", "architect", "developer", "reviewer"):
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "high")
|
||||
for a in ("tester", "deployer"):
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "medium")
|
||||
for a, e in CANON_EFFORT.items():
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", e)
|
||||
monkeypatch.setattr(P.settings, "projects_json", "")
|
||||
reload_projects()
|
||||
yield
|
||||
@@ -50,19 +59,40 @@ def _install_registry(monkeypatch, agent_efforts):
|
||||
monkeypatch.setattr(P, "_BY_REPO", {p.repo: p for p in reg})
|
||||
|
||||
|
||||
# ---- default split ----------------------------------------------------------
|
||||
# ---- TC-01: canonical defaults (AC-1 / FR-4) --------------------------------
|
||||
def test_default_split():
|
||||
assert resolve_agent_effort("developer") == "high"
|
||||
assert resolve_agent_effort("developer") == "xhigh"
|
||||
assert resolve_agent_effort("architect") == "high"
|
||||
assert resolve_agent_effort("tester") == "medium"
|
||||
assert resolve_agent_effort("deployer") == "medium"
|
||||
|
||||
|
||||
# ---- level 4: nothing -> "" -------------------------------------------------
|
||||
def test_no_config_returns_empty(monkeypatch):
|
||||
@pytest.mark.parametrize("agent,expected", list(CANON_EFFORT.items()))
|
||||
def test_canonical_effort_all_roles(agent, expected):
|
||||
assert resolve_agent_effort(agent) == expected
|
||||
|
||||
|
||||
# ---- TC-02: empty env -> per-role floor (variant c, AC-2) -------------------
|
||||
@pytest.mark.parametrize("agent,expected", list(CANON_EFFORT.items()))
|
||||
def test_empty_env_falls_back_to_per_role_floor(monkeypatch, agent, expected):
|
||||
"""Models the prod bug: ORCH_AGENT_EFFORT_*= present-but-empty -> every level
|
||||
resolves to '' on the instance; the per-role floor (config class-default) must
|
||||
still yield the canonical level (NOT '')."""
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
for a in CANON_EFFORT:
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "")
|
||||
result = resolve_agent_effort(agent)
|
||||
assert result == expected
|
||||
assert result != ""
|
||||
|
||||
|
||||
# ---- unknown agent floor degrades to default (high), never '' ---------------
|
||||
def test_empty_env_unknown_agent_floor_is_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_tester", "")
|
||||
assert resolve_agent_effort("tester") == ""
|
||||
# An agent with no agent_effort_<name> field falls back to the
|
||||
# agent_effort_default class-default (high), a safe non-empty floor.
|
||||
assert resolve_agent_effort("nonexistent_role") == "high"
|
||||
|
||||
|
||||
# ---- level 2: per-agent env beats default -----------------------------------
|
||||
@@ -103,6 +133,45 @@ def test_all_valid_efforts_pass(monkeypatch):
|
||||
assert resolve_agent_effort("developer") == e
|
||||
|
||||
|
||||
# ---- TC-03: floor does NOT mask a typo (FR-3 / AC-5) ------------------------
|
||||
def test_floor_does_not_mask_typo(monkeypatch):
|
||||
"""An explicit invalid value is non-empty, so the floor is NOT applied: the
|
||||
value is validated and dropped to '' (never-break ORCH-41), even though the
|
||||
developer floor (xhigh) exists."""
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "turbo")
|
||||
assert resolve_agent_effort("developer") == ""
|
||||
|
||||
|
||||
# ---- TC-04: priority preserved — explicit config beats floor (FR-2) ---------
|
||||
def test_explicit_env_beats_floor(monkeypatch):
|
||||
"""Operator may deliberately downgrade developer to high; the explicit
|
||||
non-empty env wins over the xhigh floor."""
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "high")
|
||||
assert resolve_agent_effort("developer") == "high"
|
||||
|
||||
|
||||
def test_default_beats_floor(monkeypatch):
|
||||
"""A non-empty global default wins over the per-role floor (floor is strictly
|
||||
below default): default=max with empty per-agent -> max, not the xhigh floor."""
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "max")
|
||||
assert resolve_agent_effort("developer") == "max"
|
||||
|
||||
|
||||
def test_project_override_beats_floor(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
_install_registry(monkeypatch, {"developer": "high"})
|
||||
assert resolve_agent_effort("developer", ORCH_PLANE_ID) == "high"
|
||||
|
||||
|
||||
# ---- TC-05: xhigh is a valid effort (FR-5) ----------------------------------
|
||||
def test_xhigh_is_valid():
|
||||
assert "xhigh" in VALID_EFFORTS
|
||||
# developer canonical xhigh resolves (is not dropped by validation)
|
||||
assert resolve_agent_effort("developer") == "xhigh"
|
||||
|
||||
|
||||
# ---- flag assembly (mirror of launcher cmd construction) --------------------
|
||||
def _build_flags(model, effort, fb):
|
||||
model_flag = f"--model {model} " if model else ""
|
||||
@@ -111,6 +180,7 @@ def _build_flags(model, effort, fb):
|
||||
return f"{model_flag}{effort_flag}{fb_flag}"
|
||||
|
||||
|
||||
# ---- TC-06: flag assembly (AC-3) --------------------------------------------
|
||||
def test_flags_present_when_configured(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_fallback_model", "claude-sonnet-4-6")
|
||||
model = resolve_agent_model("developer")
|
||||
@@ -118,21 +188,32 @@ def test_flags_present_when_configured(monkeypatch):
|
||||
fb = settings.agent_fallback_model
|
||||
flags = _build_flags(model, effort, fb)
|
||||
assert "--model claude-opus-4-8 " in flags
|
||||
assert "--effort high " in flags
|
||||
assert "--effort xhigh " in flags
|
||||
assert "--fallback-model claude-sonnet-4-6 " in flags
|
||||
|
||||
|
||||
def test_flags_absent_when_empty(monkeypatch):
|
||||
def test_flags_effort_per_role(monkeypatch):
|
||||
"""developer -> --effort xhigh; tester -> --effort medium (mirrors _spawn)."""
|
||||
assert "--effort xhigh " in _build_flags("", resolve_agent_effort("developer"), "")
|
||||
assert "--effort medium " in _build_flags("", resolve_agent_effort("tester"), "")
|
||||
|
||||
|
||||
def test_flags_absent_when_effort_empty():
|
||||
"""When the resolved effort is empty, --effort is omitted entirely. Mirrors the
|
||||
`f"--effort {effort} " if effort else ""` branch in _spawn (AC-3 negative case)."""
|
||||
flags = _build_flags("", "", "")
|
||||
assert flags == ""
|
||||
assert "--effort" not in flags
|
||||
|
||||
|
||||
def test_flags_absent_when_model_empty(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_model_default", "")
|
||||
monkeypatch.setattr(settings, "agent_model_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_fallback_model", "")
|
||||
model = resolve_agent_model("developer")
|
||||
effort = resolve_agent_effort("developer")
|
||||
fb = settings.agent_fallback_model
|
||||
flags = _build_flags(model, effort, fb)
|
||||
flags = _build_flags(model, "", fb)
|
||||
assert flags == ""
|
||||
assert "--model" not in flags
|
||||
assert "--effort" not in flags
|
||||
assert "--fallback-model" not in flags
|
||||
assert "--fallback-model" not in flags
|
||||
|
||||
@@ -23,7 +23,9 @@ os.environ.setdefault("ORCH_DB_PATH",
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src.agents.launcher import resolve_agent_model
|
||||
import logging
|
||||
|
||||
from src.agents.launcher import resolve_agent_model, is_valid_model
|
||||
from src.config import settings
|
||||
from src import projects as P
|
||||
from src.projects import ProjectConfig, reload_projects, _parse_projects_json
|
||||
@@ -154,3 +156,86 @@ def test_parse_projects_json_malformed_override_ignored():
|
||||
'"agent_models":"oops"}]')
|
||||
parsed = _parse_projects_json(raw)
|
||||
assert parsed is not None and parsed[0].agent_models == {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ORCH-074 (G2): model-name validation, never-break. is_valid_model is a
|
||||
# structural format check (^claude-…$), applied on top of the ORCH-41 cascade so
|
||||
# garbage at any level is logged and skipped, never passed to --model.
|
||||
# =============================================================================
|
||||
|
||||
# ---- is_valid_model predicate (the single G2 contract) ----------------------
|
||||
def test_is_valid_model_accepts_canonical():
|
||||
assert is_valid_model("claude-opus-4-8") is True
|
||||
assert is_valid_model("claude-sonnet-4-6") is True
|
||||
# forward-compatible: a future version passes without a code change
|
||||
assert is_valid_model("claude-opus-4-9") is True
|
||||
# surrounding whitespace is tolerated (stripped)
|
||||
assert is_valid_model(" claude-opus-4-8 ") is True
|
||||
|
||||
|
||||
def test_is_valid_model_rejects_garbage():
|
||||
assert is_valid_model("") is False
|
||||
assert is_valid_model(" ") is False
|
||||
assert is_valid_model(None) is False
|
||||
assert is_valid_model("gpt-4") is False # another provider
|
||||
assert is_valid_model("claud-opus-typo") is False # wrong prefix
|
||||
assert is_valid_model("Claude-Opus-4-8") is False # uppercase not allowed
|
||||
assert is_valid_model("claude-opus 4 8") is False # spaces inside
|
||||
|
||||
|
||||
# ---- TC-03: garbage in agent_model_<agent> -> fall back to default ----------
|
||||
def test_garbage_per_agent_env_falls_back_to_default(monkeypatch, caplog):
|
||||
monkeypatch.setattr(settings, "agent_model_developer", "gpt-4")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = resolve_agent_model("developer")
|
||||
assert result == "claude-opus-4-8" # dropped garbage, used default
|
||||
assert any("Invalid model name" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ---- TC-04: garbage in project-override -> fall back to next valid level -----
|
||||
def test_garbage_project_override_falls_back_to_default(monkeypatch, caplog):
|
||||
_install_registry(monkeypatch, {"developer": "claud-opus-typo"})
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = resolve_agent_model("developer", ORCH_PLANE_ID)
|
||||
assert result == "claude-opus-4-8" # override dropped, default used
|
||||
assert any("Invalid model name" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ---- TC-05: both override and default invalid -> "" (no --model), no raise ---
|
||||
def test_all_levels_invalid_returns_empty(monkeypatch, caplog):
|
||||
monkeypatch.setattr(settings, "agent_model_default", "totally-bogus")
|
||||
_install_registry(monkeypatch, {"developer": "gpt-4"})
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = resolve_agent_model("developer", ORCH_PLANE_ID)
|
||||
assert result == "" # never returns garbage; CLI default applies
|
||||
# both invalid levels were logged
|
||||
assert sum("Invalid model name" in r.message for r in caplog.records) >= 2
|
||||
|
||||
|
||||
# ---- TC-06: valid canonical name passes unchanged (ORCH-41 regression) -------
|
||||
def test_valid_canonical_unchanged():
|
||||
assert resolve_agent_model("developer") == "claude-opus-4-8"
|
||||
|
||||
|
||||
# ---- TC-07: all 6 agents resolve to claude-opus-4-8 (routing G3 off) ---------
|
||||
def test_all_six_agents_resolve_to_opus_4_8():
|
||||
for agent in ("analyst", "architect", "developer", "reviewer", "tester",
|
||||
"deployer"):
|
||||
assert resolve_agent_model(agent) == "claude-opus-4-8"
|
||||
|
||||
|
||||
# ---- TC-08: valid per-project override still passes validation (AC-8) --------
|
||||
def test_valid_per_project_override_unchanged(monkeypatch):
|
||||
_install_registry(monkeypatch, {"reviewer": "claude-sonnet-4-6"})
|
||||
assert resolve_agent_model("reviewer", ORCH_PLANE_ID) == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ---- TC-09 / TC-11: G4 fallback is OFF (ADR-001 decision 3) ------------------
|
||||
def test_fallback_model_disabled_by_default():
|
||||
# G4 not enabled: agent_fallback_model stays "" -> no --fallback-model flag.
|
||||
assert settings.agent_fallback_model == ""
|
||||
# never-break: the SAME predicate guards the inline fallback read in _spawn,
|
||||
# so a typo there would be rejected exactly like a model name.
|
||||
assert is_valid_model("claude-bad typo") is False
|
||||
assert is_valid_model("") is False
|
||||
|
||||
Reference in New Issue
Block a user