Compare commits
54 Commits
docs/ORCH-
...
dd4aaebe84
| Author | SHA1 | Date | |
|---|---|---|---|
| dd4aaebe84 | |||
| f645090e4d | |||
| ee4773f5b0 | |||
| 4597a8471d | |||
| b478b38df5 | |||
| 99cafefba6 | |||
| 85cfce451f | |||
| a23d4c0971 | |||
|
|
49fad5e458 | ||
| d9bb8d5fe3 | |||
| 32cc965f84 | |||
| 81fc2df8a8 | |||
| a7b27f2235 | |||
| 36c7a68722 | |||
| 18fb2eb17d | |||
| c86dc3ca95 | |||
| 77714aa318 | |||
| 493b9be9c4 | |||
|
|
1b095282bf | ||
| 9c19588bcd | |||
| fe3f1658ba | |||
| 595c382ac7 | |||
| aa488edddf | |||
| f2161451a0 | |||
| 0e7d608fc0 | |||
| fb9390e216 | |||
| 92817889c4 | |||
|
|
baf7860822 | ||
| 2cf40c1af9 | |||
| 44ef0bb570 | |||
| d826eacfcf | |||
| a482b36dae | |||
| f452626bb8 | |||
| b46fc6e51b | |||
| 140827f4da | |||
| fc29ba76ec | |||
|
|
9834dae108 | ||
| 039322001a | |||
| 1997376eb5 | |||
| 0ab6a33ef5 | |||
| 74269b467c | |||
| 781f9df26c | |||
| c0715ad55b | |||
| 7ee528ad7b | |||
| 2861dea613 | |||
| 50434fc2b1 | |||
|
|
6eb9992585 | ||
| e9b23d3c04 | |||
| e3c3292ec7 | |||
| 1ada41f272 | |||
| 62b4d1f7d1 | |||
| c5007e6c90 | |||
| 10510ac48c | |||
| 8ccd17e199 |
29
.env.example
29
.env.example
@@ -37,12 +37,15 @@ ORCH_AGENT_MODEL_DEVELOPER=
|
||||
ORCH_AGENT_MODEL_REVIEWER=
|
||||
ORCH_AGENT_MODEL_TESTER=
|
||||
ORCH_AGENT_MODEL_DEPLOYER=
|
||||
# Effort split: thinking agents (analyst/architect/developer/reviewer) -> high;
|
||||
# mechanical agents (tester/deployer) -> medium.
|
||||
# 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=high
|
||||
ORCH_AGENT_EFFORT_DEVELOPER=xhigh
|
||||
ORCH_AGENT_EFFORT_REVIEWER=high
|
||||
ORCH_AGENT_EFFORT_TESTER=medium
|
||||
ORCH_AGENT_EFFORT_DEPLOYER=medium
|
||||
@@ -104,6 +107,20 @@ ORCH_PREMERGE_REBASE_ALWAYS=true
|
||||
# cache them into job_deps (the scheduler then reads only the DB).
|
||||
ORCH_TASK_DEPS_ENABLED=true
|
||||
ORCH_TASK_DEPS_SOURCE=db
|
||||
# ORCH-088 (Stage 1, serial e2e): per-repo serial gate. A NEW task's analyst-job does
|
||||
# NOT enter analysis (no branch cut, no analyst) while the same repo has an EARLIER
|
||||
# unfinished task (FIFO, tasks.id < the job's task) OR the repo is frozen. The branch
|
||||
# cut is DEFERRED from start_pipeline to the analyst-job claim so its base is a fresh
|
||||
# origin/main already containing the predecessor (anti-stale-base). Gate lives in
|
||||
# claim_next_job (offline hot-path, fail-OPEN on error); freeze (FR-5) is a durable
|
||||
# repo_freeze row set on post-deploy DEGRADED, cleared manually via
|
||||
# POST /serial-gate/unfreeze?repo=<repo>. Leaf src/serial_gate.py (never-raise).
|
||||
# SERIAL_GATE_ENABLED=false -> claim AND start_pipeline are 1:1 as before ORCH-088.
|
||||
# SERIAL_GATE_REPOS (CSV) -> scope; EMPTY = ALL repos (not self-hosting-only).
|
||||
# SERIAL_GATE_FREEZE_ENABLED=false -> the rollback-freeze layer is off (not set/read).
|
||||
ORCH_SERIAL_GATE_ENABLED=true
|
||||
ORCH_SERIAL_GATE_REPOS=
|
||||
ORCH_SERIAL_GATE_FREEZE_ENABLED=true
|
||||
# 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/
|
||||
@@ -120,11 +137,17 @@ ORCH_TASK_DEPS_SOURCE=db
|
||||
# REGRESSION_GUARD_ENABLED -> kill-switch for the ORCH-073 main-integrity regression
|
||||
# guard (false -> SHA-in-main alone gates done); reuses the
|
||||
# merge-verify scope, so non-self repos are a no-op.
|
||||
# MERGE_VERIFY_AUTOCREATE_PR_ENABLED -> ORCH-082: guarantee an open code-PR
|
||||
# (head==branch, base==main) via merge_gate.ensure_open_pr
|
||||
# BEFORE the deterministic merge_pr (fixes the false HOLD
|
||||
# "no open PR"). false -> exactly pre-ORCH-082 behaviour.
|
||||
# Reuses the merge-verify scope; non-self repos -> no-op.
|
||||
ORCH_MERGE_VERIFY_ENABLED=true
|
||||
ORCH_MERGE_VERIFY_REPOS=
|
||||
ORCH_MERGE_PR_TIMEOUT_S=60
|
||||
ORCH_MERGE_VERIFY_TIMEOUT_S=60
|
||||
ORCH_REGRESSION_GUARD_ENABLED=true
|
||||
ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true
|
||||
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
|
||||
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
|
||||
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Work item: ORCH-061
|
||||
Work item: ORCH-088
|
||||
Repo: orchestrator
|
||||
Branch: feature/ORCH-061-bug-deploy-staging-development
|
||||
Branch: feature/ORCH-088-orch-88-10-20
|
||||
Stage: development
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
File diff suppressed because one or more lines are too long
27
CLAUDE.md
27
CLAUDE.md
@@ -7,7 +7,7 @@
|
||||
- Backend: FastAPI + uvicorn (Python 3.12)
|
||||
- БД: SQLite (`src/db.py`)
|
||||
- Агенты: 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`).
|
||||
- Очередь задач: собственная (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`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты.
|
||||
- Контейнеризация: Docker + Compose
|
||||
- CI/CD: Gitea Actions (`.gitea/workflows/`)
|
||||
- Деплой: docker compose на mva154
|
||||
@@ -41,19 +41,40 @@ created → analysis → architecture → development → review → testing →
|
||||
## Статусная модель Plane (ORCH-066) — индикация ≠ управление
|
||||
Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`.
|
||||
|
||||
## Нотификации / Telegram live-tracker (ORCH-042/066/067)
|
||||
## Нотификации / Telegram live-tracker (ORCH-042/066/067/087)
|
||||
Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки:
|
||||
- **Дефолт `tracker_mode` — `bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`).
|
||||
`bump` на каждом обновлении удаляет старую карточку и шлёт свежую вниз чата (тихо), `edit`
|
||||
редактирует на месте. Инвариант «одна карточка на задачу» — в обоих режимах.
|
||||
- **Зачистка сирот (ORCH-087):** bump ведёт авторитетный леджер ВСЕХ созданных карточек
|
||||
(таблица `tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении удаляет
|
||||
ВСЕ незакрытые mid, а не только скаляр `tracker_message_id` (он сохранён как указатель на
|
||||
текущую карточку, BC). Это устраняет класс «замёрзшая сирота» (старая карточка с заголовком
|
||||
ранней стадии, потерявшая ссылку при гонке/`delete`-fail+`send`-ok). Новый mid пишется в
|
||||
леджер ТОЛЬКО при успешном `send` (BR-6); transient-`delete` остаётся незакрытым для ретрая;
|
||||
«already gone»/>48ч (`_DELETE_GONE_MARKERS`) → закрывается. Остаточная гонка самозалечивается
|
||||
за один bump. Known-limitation: Telegram 48ч (сироты старше неудаляемы).
|
||||
- **Эффорт в строке стадии (ORCH-087):** колонка `agent_runs.effort` стампится фактическим
|
||||
`resolve_agent_effort` в `launcher._spawn` (CLI его в result-JSON не возвращает); строка
|
||||
рендерится `· {model} · {effort}` (developer=`xhigh`, tester/deployer=`medium`, прочие=`high`);
|
||||
пустой/исторический effort → суффикс опускается.
|
||||
- **Честное итоговое время (ORCH-087):** done-строка = три независимых подписанных метрики
|
||||
`⏱️ Агенты {Σ agent_runs} · твоё {review~cap} · общее с ожиданием {wall}` (раньше `Всего {wall}`
|
||||
читалось как сумма, которой не является). «Твоё» ограничено `tracker_brd_review_cap_s`
|
||||
(`ORCH_TRACKER_BRD_REVIEW_CAP_S`, дефолт 2ч; маркер `~` при отсечке аномального застоя).
|
||||
- **Статус-строка карточки** (`📍 <status_label>`) показывает текущий Plane-статус по модели
|
||||
ORCH-066 (`plane_status_label`). Оффлайн-ядро (`stage → статус`, In Review из brd-clock)
|
||||
работает всегда без сети; best-effort live-overlay (kill-switch `tracker_live_status`,
|
||||
TTL-кэш, короткий таймаут) лишь дорисовывает ветки, неотличимые offline (Needs Input /
|
||||
Blocked / Rejected / Cancelled / Deploying / Monitoring) и **никогда не блокирует конвейер**.
|
||||
Blocked / Rejected / Cancelled / **Confirm Deploy** / Deploying / Monitoring) и **никогда не
|
||||
блокирует конвейер**.
|
||||
- **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех
|
||||
уведомлениях (`notify_*`, alert'ы стадий) рендерится как `<a href=…>` на issue в Plane;
|
||||
fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает.
|
||||
- **Без link-preview (ORCH-080):** оба примитива (`send_telegram`/`edit_telegram`) шлют
|
||||
payload с `disable_web_page_preview: True` — баннер Plane («Modern project management»)
|
||||
под кликабельной ссылкой `ORCH-NNN` больше не разворачивается ни в карточке (`bump`/`edit`),
|
||||
ни в notify/alert-сообщениях. `parse_mode: HTML` сохранён → ссылка остаётся кликабельной.
|
||||
- Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification`
|
||||
(карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются.
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ uvicorn src.main:app --reload --port 8500
|
||||
| `ORCH_REPOS_DIR` | Repos dir (container) | `/repos` |
|
||||
| `ORCH_HOST_REPOS_DIR` | Repos dir (host) | `/home/slin/repos` |
|
||||
| `ORCH_DB_PATH` | SQLite path | `/app/data/orchestrator.db` |
|
||||
| `ORCH_RUNS_DIR` | Базовый каталог per-run логов агентов (`<runs_dir>/{run_id}.log`, ORCH-087) | `/app/data/runs` |
|
||||
| `ORCH_MAX_CONCURRENCY` | Сколько jobs воркер запускает параллельно (ORCH-1) | `1` |
|
||||
| `ORCH_QUEUE_POLL_INTERVAL` | Период опроса очереди воркером, сек (ORCH-1) | `2.0` |
|
||||
| `ORCH_PREFLIGHT_CACHE_TTL` | Кэш preflight (CLI/net), сек (ORCH-1 resilience) | `45` |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- **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.
|
||||
- **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 на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7 и [ADR-001](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md).
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
|
||||
|
||||
@@ -42,13 +42,13 @@ 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-дефолт (без флага)**. frontmatter `model:` в `.openclaw/agents/*.md` **удалён** (ORCH-74 G1) — он был мёртвой/лживой декларацией (launcher его не читает); config — единственный источник правды о модели. Model-routing (G3) НЕ включён — все 6 агентов на `claude-opus-4-8`.
|
||||
Модель и `--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 | 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 |
|
||||
@@ -92,6 +92,44 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
|
||||
|
||||
Подробнее: [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`.
|
||||
|
||||
### Per-repo serial gate: пакетный автономный режим (ORCH-088 — реализовано)
|
||||
Эпик «10–20 задач за ночь», Этап 1 (serial e2e). Закрывает **stale-анализ**: ветка задачи N+1
|
||||
срезалась на входе в анализ (`start_pipeline._create_gitea_branch`) от `main`, ещё не содержащего код
|
||||
предшественника N (физическое код-затирание уже закрыто ORCH-026; ORCH-088 — **логический** разрыв).
|
||||
Новая задача репо не входит в `analysis` (не режет ветку, не запускает analyst), пока в том же репо
|
||||
есть незавершённая задача (`stage != 'done'`) или репо заморожен. Аддитивно, под kill-switch, область
|
||||
репо, never-raise, restart-safe; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` — **без изменений**.
|
||||
- **Gate-в-claim** (`db.claim_next_job`) — analyst-job (`jobs.agent='analyst'`) применимого репо не
|
||||
выбирается, если `EXISTS` **более ранняя** незавершённая задача репо (`t2.id < jobs.task_id`) ИЛИ
|
||||
активна строка `repo_freeze`. По образцу `task_deps` `NOT EXISTS` (ORCH-026); только локальная БД
|
||||
(offline hot-path, NFR-2). Job'ы уже активной задачи проходят свободно. **FIFO-уточнение реализации
|
||||
(FR-2):** ADR-001 D1 фиксировал псевдо-SQL `t2.id != jobs.task_id`; при `!=` пакет одновременно
|
||||
созданных свежих задач (все в `analysis`) взаимно блокировался бы (каждая — «другая незавершённая»
|
||||
для остальных) ⇒ дедлок всей serial-очереди. `<` допускает ровно самую раннюю задачу и сериализует
|
||||
остальные за ней (строго по одной, FIFO по `jobs.id`), при этом по-прежнему не блокирует rework-analyst
|
||||
собственной задачи (R-7) и сохраняет AC-1.
|
||||
- **Отложенный срез ветки (анти-stale-base, AC-6):** для применимого репо `start_pipeline` создаёт
|
||||
task-row + enqueue analyst, но **не** создаёт Gitea-ветку/docs; срез релоцируется на момент claim
|
||||
analyst-job (launcher), когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main,
|
||||
ORCH-071/073). `ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно
|
||||
(`_create_gitea_branch` 409 = no-op).
|
||||
- **Durable per-repo freeze** (новая аддитивная таблица `repo_freeze`, `cleared_at IS NULL` = активен) —
|
||||
post-deploy `DEGRADED`/rollback (ORCH-021) → `set_repo_freeze` + Telegram-алерт; gate закрыт
|
||||
безусловно до **ручного** снятия (`POST /serial-gate/unfreeze`). Деградировавшая задача уже `done`
|
||||
(BR-7) ⇒ отдельный сигнал, независимый от `stage`.
|
||||
- **Согласование NFR-1:** hot-claim тотальный сбой построения gate-фрагмента → **fail-open** (не
|
||||
заклинить очередь всех проектов, AC-8); freeze в Python-слое (`is_repo_frozen`) → **fail-closed**
|
||||
(безопасность прода, AC-9).
|
||||
- Чистая логика — leaf `src/serial_gate.py` (never-raise). Флаги `serial_gate_enabled` (kill-switch),
|
||||
`serial_gate_repos` (CSV; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58),
|
||||
`serial_gate_freeze_enabled`. Наблюдаемость — аддитивный блок `serial_gate` в `GET /queue`
|
||||
(per-repo `active_task` / `waiting` / `frozen`). Cross-repo параллелизм сохранён (FR-3); при
|
||||
выключенном флаге — нулевая регрессия (enduro не затронут).
|
||||
|
||||
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md), детально —
|
||||
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
|
||||
`docs/work-items/ORCH-088/08-data-requirements.md`.
|
||||
|
||||
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
|
||||
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
|
||||
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
|
||||
@@ -206,6 +244,39 @@ merge-в-main вообще**. Detached host-деплой лишь retag'ал о
|
||||
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`,
|
||||
`docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.
|
||||
|
||||
#### Гарантированный код-PR перед merge-verify (ORCH-082 — фикс ложного HOLD «no open PR»)
|
||||
Под-гейт merge-verify (ORCH-071/073) детерминированно мержит **открытый** код-PR ветки в `main`
|
||||
(`merge_pr`, фильтр `head.ref==branch` И `base.ref=="main"`). Но конвейер **не гарантировал**, что
|
||||
к моменту merge у ветки этот PR есть: PR создаётся единственной `launcher._ensure_pr` **только** на
|
||||
developer-пути и **только** при свежем worktree-коммите. На деплое ORCH-074 (08.06, первая задача
|
||||
после ручных восстановлений `main`) у ветки не оказалось открытого код-PR → `merge_pr` вернул
|
||||
`("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но это
|
||||
лечило следствие. ORCH-082 закрывает **отсутствующий инвариант** «к merge-verify у ветки есть
|
||||
открытый код-PR» аддитивно, внутри того же под-гейта, не трогая машину стадий:
|
||||
- **Новый leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)`** (never-raise):
|
||||
`GET …/pulls?state=open` с фильтром `head.ref==branch` И `base.ref=="main"` (**идентичен**
|
||||
`merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base != main` НЕ код-PR) → `("existed", N)`; иначе
|
||||
`POST …/pulls` → `("created", N)`; гонка «PR exists»/409/422 → повторный GET → `existed` (без
|
||||
дублей); любая иная ошибка → `("failed", reason)`.
|
||||
- **Врезка в `_handle_merge_verify`** ПОСЛЕ резолва `validated_revision` и **ПЕРЕД** `merge_pr`:
|
||||
`created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD+alert
|
||||
через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось» — отличим от
|
||||
not-merged HOLD; `result.note="pr-create-failed-hold"`), задача остаётся на `deploy`, БЕЗ отката
|
||||
на development.
|
||||
- **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО
|
||||
`verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь
|
||||
**ложный** HOLD «no open PR», но не маскирует реально невлитый код (тот → HOLD как прежде).
|
||||
- **`launcher._ensure_pr`** рекомендуется делегировать в `ensure_open_pr` (единый код создания PR),
|
||||
сохранив прежний триггер «только developer-путь».
|
||||
- **Условность как ORCH-35/43/58/71:** kill-switch `merge_verify_autocreate_pr_enabled` (дефолт
|
||||
`true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); non-self —
|
||||
no-op. `False` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), **без
|
||||
миграции БД** (restart-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
|
||||
exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push.
|
||||
|
||||
Подробнее: [adr-0016](adr/adr-0016-ensure-open-pr-before-merge-verify.md) (amends 0013/0014);
|
||||
детально — `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`.
|
||||
|
||||
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
||||
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
||||
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
|
||||
@@ -301,6 +372,42 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
|
||||
Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально —
|
||||
`docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`.
|
||||
|
||||
### Live-трекер: зачистка сирот + эффорт в карточке + честное время (ORCH-087 — реализовано)
|
||||
Скалярный `tasks.tracker_message_id` (только последний `message_id`) при рассинхроне
|
||||
bump-режима (доминанты: гонка двух `update_task_tracker` и delete-fail+send-ok)
|
||||
терял ссылку на прежние карточки → **осиротевшие «замёрзшие»** карточки (скриншот
|
||||
ORCH-082: `📍 To Analyse` на задаче, реально дошедшей до `deploy`). G0-расследование
|
||||
([ADR-001](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md)):
|
||||
рендер исправен, корень — потеря учёта старых mid. Решение (bump сохраняется как
|
||||
дефолт — фича «карточка внизу» ORCH-042/067):
|
||||
- **G1 — полный учёт mid:** аддитивная таблица-леджер `tracker_messages(task_id,
|
||||
message_id, created_at, deleted_at)` (вариант A1; JSON-массив A2 отклонён —
|
||||
lost-update при гонке). На каждом bump зачищаются ВСЕ незакрытые mid (`deleted_at
|
||||
IS NULL`): успех/«already gone» → `deleted_at`, transient → остаётся для ретрая;
|
||||
новый mid в леджер + `set_tracker_message_id` ТОЛЬКО при `send is not None` (BR-6).
|
||||
Скаляр `tracker_message_id` сохранён (BC). Остаточная гонка самозалечивается за один
|
||||
переход (лок не вводится). Known-limitation: Telegram 48ч (сироты старше неудаляемы).
|
||||
- **G2/G3 — заголовок/deploy-цикл:** после G1 единственная живая карточка несёт
|
||||
заголовок текущей стадии; `_LIVE_BRANCH_LABELS` дополняется ключом `confirm_deploy`
|
||||
(полнота цикла `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done`).
|
||||
- **BR-EFF — эффорт в строке стадии:** новая колонка `agent_runs.effort TEXT`,
|
||||
стамп фактического `resolve_agent_effort` в `launcher._spawn` (CLI эффорт не
|
||||
возвращает); рендер `· {model} · {effort}` (developer=`xhigh`, tester/deployer=
|
||||
`medium`, прочие=`high`); пустой → суффикс опускается.
|
||||
- **BR-G5 — честное время:** done-строка `⏱️ Агенты {agent} · твоё {review~cap} ·
|
||||
общее с ожиданием {wall}` — три независимых подписанных метрики; `agent`=Σ
|
||||
`agent_runs` (главная, точная); «твоё» ограничено порогом
|
||||
`tracker_brd_review_cap_s` (дефолт 2ч, маркер `~` при отсечке аномального застоя);
|
||||
`wall` подписан «с ожиданием», не выдаётся за сумму.
|
||||
- **Инварианты:** `STAGE_TRANSITIONS`/`QG_CHECKS`/стадии — без изменений; миграции
|
||||
аддитивны/идемпотентны (общая прод-БД, enduro не трогается); never-raise,
|
||||
`disable_notification`, `plane_issue_link` (ORCH-067), `disable_web_page_preview`
|
||||
(ORCH-080) — сохранены; разработка поверх свежего `origin/main` (ORCH-86),
|
||||
`reconciler.py` не эродируется.
|
||||
|
||||
Детально — [ADR-001](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md),
|
||||
`docs/work-items/ORCH-087/08-data-requirements.md`.
|
||||
|
||||
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
|
||||
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
|
||||
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
|
||||
@@ -318,6 +425,18 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
|
||||
ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A —
|
||||
запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард
|
||||
retry-count проверяется первым (дёшево, локальный SQL).
|
||||
**ORCH-086 (закрытие F-1-пробела ORCH-068):** терминал-исключение и `state_uuid`-dedup
|
||||
(изначально только F-2) распространены на F-1. После дешёвых локальных гардов F-1 делает
|
||||
**один** резолв Plane-статуса задачи на тик (общий fetch для Guard 2 + терминал-скипа +
|
||||
`_note_unblock`); терминальная задача (группа Plane `completed`/`cancelled`, fallback —
|
||||
логические ключи `done`/`cancelled`, ЛИБО стадия в БД орка ∈ `{done, cancelled}`) →
|
||||
**безусловный** ранний скип (`skipped_terminal_total++`, без `advance`/уведомления; не подчинён
|
||||
`reconcile_skip_blocked_enabled`). Вызов `_note_unblock` на F-1 теперь передаёт `state_uuid` →
|
||||
in-memory dedup работает на обоих путях (страховка от повтора после рестарта). Лечит
|
||||
периодическое ложное «ET-002 done разблокирована (потерян webhook)» для терминальных в Plane
|
||||
задач (enduro/orchestrator), сохраняя легитимный unblock реально застрявшей не-терминальной
|
||||
задачи. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/сигнатуры/новые флаги — без изменений. Детали —
|
||||
`docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md`.
|
||||
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
|
||||
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
|
||||
**ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane
|
||||
@@ -480,6 +599,7 @@ Monitoring after Deploy → Done
|
||||
- `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»
|
||||
- `repo_freeze` — durable per-repo rollback-freeze (ORCH-088, FR-5): `(id, repo, frozen_at, reason, work_item_id, cleared_at)`, аддитивная append-only; активный freeze ⇔ строка репо с `cleared_at IS NULL`. Выставляется post-deploy `DEGRADED` (`set_repo_freeze`), снимается вручную (`POST /serial-gate/unfreeze` → `cleared_at=now`). Гейтит serial-claim безусловно (деградировавшая задача уже `done`)
|
||||
|
||||
## Изоляция (git worktree, ORCH-2)
|
||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||
@@ -489,7 +609,8 @@ Monitoring after Deploy → Done
|
||||
|--------|------|----------|
|
||||
| GET | `/health` | health check |
|
||||
| GET | `/status` | активные задачи (stage != done) |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + последние jobs |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + последние jobs |
|
||||
| POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=<repo>`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` |
|
||||
| POST | `/webhook/plane` | Plane webhook |
|
||||
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||
|
||||
@@ -507,3 +628,4 @@ Monitoring after Deploy → Done
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).*
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).*
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-068 (D1 терминал-гард по группе `_is_terminal_state` + `get_project_state_groups` в src/plane_sync.py; D2 сравнение стадии до/после `_dispatch` + дедуп-словарь в src/reconciler.py; TTL-запись `_STATES_CACHE` + флаг `plane_states_ttl_s` в src/config.py; счётчики `skipped_terminal_total`/`deduped_total` в `/queue`; обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`get_project_state_groups`/`_STATES_CACHE`).*
|
||||
*Актуально на 2026-06-09. Статус доработки: ORCH-088 (per-repo serial gate, Этап 1 serial e2e, adr-0017, `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`) — реализовано в ветке feature/ORCH-088 (leaf src/serial_gate.py never-raise: gate-фрагмент в src/db.py `claim_next_job` fail-OPEN c FIFO-условием `t2.id < jobs.task_id` + freeze `repo_freeze.cleared_at IS NULL`, freeze-решения fail-CLOSED; отложенный срез ветки src/webhooks/plane.py `start_pipeline` → src/agents/launcher.py `_materialize_deferred_branch` (sync `asyncio.run` в worker-потоке) при claim analyst-job; durable freeze таблица `repo_freeze` (idempotent миграция в init_db) + `set_repo_freeze` в src/stage_engine.py DEGRADED-ветке `run_post_deploy_monitor` + ручное снятие `POST /serial-gate/unfreeze` в src/main.py; флаги `serial_gate_enabled`/`serial_gate_repos`/`serial_gate_freeze_enabled` в src/config.py; блок `serial_gate` в `GET /queue`; `STAGE_TRANSITIONS`/`QG_CHECKS` НЕ трогаются; обновлять также при изменении этих мест).*
|
||||
|
||||
@@ -21,12 +21,15 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| 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 |
|
||||
| adr-0016 | ensure_open_pr — гарантированный код-PR перед merge-verify | accepted | 2026-06-09 | ORCH-082 |
|
||||
| adr-0017 | Per-repo serial gate (пакетный автономный режим, serial e2e) | proposed | 2026-06-09 | ORCH-088 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0015`).
|
||||
> свободный номер (текущий максимум — `0017`).
|
||||
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
|
||||
> adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082).
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-0016: ensure_open_pr — гарантированный код-PR перед merge-verify (ORCH-082)
|
||||
|
||||
## Статус
|
||||
Accepted — амендмент к [adr-0013](adr-0013-merge-verify-gate.md) и
|
||||
[adr-0014](adr-0014-merge-verify-sha-source-of-truth.md). Детально:
|
||||
`docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`.
|
||||
|
||||
## Контекст
|
||||
Merge-verify (ORCH-071/073) — под-гейт ребра `deploy → done`: детерминированно мержит код-PR в
|
||||
`main` (`merge_pr`) и подтверждает merge **только** по «SHA-в-main» (`verify_merged_to_main`,
|
||||
ORCH-073). На деплое ORCH-074 (08.06) `merge_pr` вернул `("False", "no open PR")`: у ветки **не
|
||||
было** открытого PR с `head==branch` И `base=="main"`. Защита ORCH-073 верно удержала задачу
|
||||
(HOLD, не ложный `done`), но это лечило **следствие**.
|
||||
|
||||
Первопричина (код-аудит): PR создаётся в конвейере **единственной** функцией
|
||||
`launcher._ensure_pr`, вызываемой **только** на developer-пути и **только** при свежем
|
||||
worktree-коммите. Любой сценарий без свежего developer-коммита (бойнс без правок, повторный
|
||||
прогон, **ручное восстановление ветки/`main`** — случай ORCH-074) оставляет ветку без код-PR.
|
||||
Инвариант «к merge-verify у ветки есть открытый код-PR» в конвейере **отсутствовал** → блокер
|
||||
автономного деплоя (ORCH-54).
|
||||
|
||||
## Решение
|
||||
Аддитивно обеспечить инвариант **внутри того же под-гейта**, ПЕРЕД `merge_pr`, не трогая машину
|
||||
стадий:
|
||||
|
||||
1. **Новый leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)`** (never-raise):
|
||||
`GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен
|
||||
`merge_pr`/ORCH-073 FR-3 — авто-docs-PR не считается код-PR) → `("existed", N)`; иначе
|
||||
`POST …/pulls` → `("created", N)`; гонка «PR exists» → повторный GET → `existed` (без дублей);
|
||||
любая ошибка → `("failed", reason)`.
|
||||
2. **Врезка в `_handle_merge_verify`** ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`:
|
||||
`created|existed` → штатно к `merge_pr`; `failed` → честный HOLD+alert через новый helper
|
||||
`_hold_pr_create_failed` (текст «PR создать не удалось» — отличим от not-merged HOLD), задача
|
||||
остаётся на `deploy`, БЕЗ отката на development.
|
||||
3. **Kill-switch `merge_verify_autocreate_pr_enabled`** (дефолт `True`); область —
|
||||
`merge_verify_applies` (self-hosting / `merge_verify_repos`). `False` → поведение ORCH-074 1:1.
|
||||
4. **`launcher._ensure_pr`** рекомендуется делегировать в `ensure_open_pr` (единый код создания
|
||||
PR), сохранив прежний триггер «только developer-путь».
|
||||
|
||||
## Последствия
|
||||
- **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО
|
||||
`verify_merged_to_main` (SHA-в-main) + `check_main_regression`. Создание PR устраняет лишь
|
||||
**ложный** HOLD «no open PR», но не маскирует реально невлитый код (тот → HOLD как прежде).
|
||||
- **Без миграций:** идемпотентность выводится из Gitea (наличие открытого PR), схема БД не меняется
|
||||
— restart-safe; повторный заход (reaper/reconciler/re-approve) → `existed`, дублей нет.
|
||||
- **Инварианты целы:** `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
|
||||
exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений; `main` не
|
||||
push/force-push; never-raise на всём пути.
|
||||
- **Наблюдаемость:** один однозначный исход в логах на проход — created / existed / failed; HOLD по
|
||||
failed текстуально отличим от HOLD not-merged.
|
||||
- **Минус:** код-PR может создаваться после прохождения гейтов — безопасно, т.к. гейты валидируют
|
||||
код ветки, а merge-verify идёт ПОСЛЕ всех гейтов; PR — лишь механизм слияния, ревью не обходится.
|
||||
59
docs/architecture/adr/adr-0017-serial-gate.md
Normal file
59
docs/architecture/adr/adr-0017-serial-gate.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# adr-0017: Per-repo serial gate (пакетный автономный режим, serial e2e)
|
||||
|
||||
Статус: **proposed** · Дата: 2026-06-09 · Источник: **ORCH-088** (Этап 1)
|
||||
Детально: `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`.
|
||||
|
||||
## Контекст
|
||||
Цель эпика ORCH-088 — масштаб автономности: накидать вечером 10–20 задач и получить к утру пакет,
|
||||
последовательно проведённый через весь конвейер (analysis → … → deploy → done). Корневая проблема —
|
||||
**stale-анализ**: ветка задачи N+1 срезается на входе в анализ (`start_pipeline._create_gitea_branch`)
|
||||
от `main`, ещё не содержащего код предшественника N. Физическое код-затирание уже закрыто (ORCH-026
|
||||
auto_rebase + merge-lease); остаётся **логический** разрыв. Plane API v1 не имеет bulk/relations ⇒
|
||||
очередь/зависимости хранятся у оркестратора (gate по локальной БД).
|
||||
|
||||
## Решение
|
||||
**Per-repo serial gate** — новая задача репо не входит в `analysis` (не режет ветку, не запускает
|
||||
analyst), пока в том же репо есть незавершённая задача (`stage != 'done'`) или репо заморожен.
|
||||
Три механизма, аддитивно, под kill-switch, с областью репо, never-raise, restart-safe:
|
||||
|
||||
1. **Gate-в-claim** (`db.claim_next_job`) — analyst-job (`jobs.agent='analyst'`) применимого репо не
|
||||
выбирается, если `EXISTS` другая незавершённая задача репо ИЛИ активна строка `repo_freeze`. По
|
||||
образцу `task_deps` `NOT EXISTS` (ORCH-026); только локальная БД (offline hot-path). Job'ы уже
|
||||
активной задачи проходят свободно; rework-analyst не блокирует себя (`t2.id != jobs.task_id`).
|
||||
2. **Отложенный срез ветки** — для применимого репо `start_pipeline` создаёт task-row + enqueue
|
||||
analyst, но **не** создаёт Gitea-ветку/docs; срез релоцируется на момент claim analyst-job
|
||||
(launcher), когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main, ORCH-071/073).
|
||||
`ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно (409 = no-op).
|
||||
3. **Durable per-repo freeze** (`repo_freeze`) — post-deploy `DEGRADED`/rollback (ORCH-021) →
|
||||
`set_repo_freeze` + Telegram-алерт; gate закрыт безусловно до **ручного** снятия
|
||||
(`POST /serial-gate/unfreeze`). Деградировавшая задача уже `done` (BR-7) ⇒ нужен отдельный сигнал.
|
||||
|
||||
Чистая логика — leaf `src/serial_gate.py` (never-raise). Флаги `serial_gate_enabled` (kill-switch),
|
||||
`serial_gate_repos` (CSV; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58),
|
||||
`serial_gate_freeze_enabled`. Наблюдаемость — блок `serial_gate` в `GET /queue`.
|
||||
|
||||
## Альтернативы
|
||||
- **Гейт в `start_pipeline` + re-trigger при `done`** — больше состояния/путей, риск зависших задач;
|
||||
relocation на claim переиспользует restart-safe `jobs`-очередь.
|
||||
- **Freeze как колонка `tasks`** — неверная семантика (freeze per-repo, задача уже `done`).
|
||||
- **Self-hosting-only область** — лишает enduro анти-stale-base (FR-3).
|
||||
- **Отдельная таблица очереди ожидания** — избыточно; `jobs(queued)`+gate достаточно.
|
||||
- **Снятие freeze Plane-жестом** — перегрузка статусов (анти-паттерн ORCH-059).
|
||||
|
||||
## Последствия
|
||||
- **+** AC-6 закрыт структурно; AC-2/AC-3 «бесплатны» (ожидание = `queued` job без ветки);
|
||||
переиспользование проверенных паттернов; cross-repo параллелизм сохранён; `STAGE_TRANSITIONS` /
|
||||
`QG_CHECKS` / `check_*` / merge-gate / merge-verify / image-freshness / post-deploy / deploy-хук /
|
||||
`max_concurrency` — **без изменений**.
|
||||
- **NFR-1:** hot-claim тотальный сбой → **fail-open** (не заклинить очередь всех проектов); freeze в
|
||||
Python-слое → **fail-closed** (безопасность прода).
|
||||
- **−** Срез ветки/docs мигрируют из async в sync-путь launcher (обёртка); Blocked-задача держит пакет
|
||||
(Этап 1, осознанно); freeze снимается только вручную.
|
||||
- Откат: `serial_gate_enabled=False` ⇒ claim/старт 1:1 как до ORCH-088; таблица `repo_freeze` инертна.
|
||||
- **Вне скопа** (Этап 1): merge-очередь FIFO, pre-merge rebase как отдельная фича, фазы A/B/C,
|
||||
любой параллелизм задач внутри одного репо, зависимость от ORCH-83.
|
||||
|
||||
## Связи
|
||||
- Переиспользует: adr-0002 (очередь ORCH-1), adr-0015 (claim-gate/auto_rebase/merge-lease ORCH-026),
|
||||
adr-0010 (post-deploy monitor — источник DEGRADED), adr-0013/0014 (merge-verify ⇒ `done`⇔SHA-в-main).
|
||||
- Новая аддитивная таблица `repo_freeze` (`docs/work-items/ORCH-088/08-data-requirements.md`).
|
||||
7
docs/work-items/ORCH-080/00-business-request.md
Normal file
7
docs/work-items/ORCH-080/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-52g: убрать Telegram link-preview (логотип Plane) в уведомлениях трекера
|
||||
|
||||
Work Item ID: ORCH-080
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
73
docs/work-items/ORCH-080/01-brd.md
Normal file
73
docs/work-items/ORCH-080/01-brd.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 01-BRD — ORCH-080: убрать Telegram link-preview (логотип Plane) в уведомлениях трекера
|
||||
|
||||
Work Item ID: ORCH-080
|
||||
Эпик: ORCH-052 (под-задача ORCH-52g)
|
||||
Тип: Доработка (UX уведомлений)
|
||||
Приоритет: LOW (косметика)
|
||||
Зона: `src/notifications.py`
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Каждая задача в Telegram сопровождается одной live-карточкой трекера (`src/notifications.py`,
|
||||
ORCH-042/066/067). С ORCH-067 в карточке появился **кликабельный номер задачи** —
|
||||
`<a href="https://plane.mva154.duckdns.org/.../issues/<id>/">ORCH-NNN</a>`.
|
||||
|
||||
Telegram по умолчанию разворачивает **link-preview** (web page preview) для первой ссылки
|
||||
в сообщении. Из-за ссылки на Plane под каждым сообщением трекера раскрывается крупный
|
||||
баннер-превью **«Plane — Modern project management»**.
|
||||
|
||||
**Жалоба (Слава, 08.06):** баннер уродует ленту чата и дублируется на каждой задаче/каждом
|
||||
обновлении карточки (особенно заметно в дефолтном режиме `bump`, где карточка пересоздаётся
|
||||
на каждом переходе).
|
||||
|
||||
## 2. Диагностика (код-аудит `src/notifications.py`)
|
||||
|
||||
| Функция | Эндпоинт | Текущий JSON-payload | Превью |
|
||||
|---------|----------|----------------------|--------|
|
||||
| `send_telegram()` (стр. 52-62) | `POST /sendMessage` | `chat_id`, `text`, `parse_mode: HTML`, `disable_notification` | **разворачивается** (нет `disable_web_page_preview`) |
|
||||
| `edit_telegram()` (стр. 165-174) | `POST /editMessageText` | `chat_id`, `message_id`, `text`, `parse_mode: HTML` | **разворачивается** (нет `disable_web_page_preview`) |
|
||||
|
||||
Причина баннера: оба payload **не содержат** ключ `disable_web_page_preview`. Telegram Bot API
|
||||
по умолчанию (отсутствие ключа) включает превью.
|
||||
|
||||
`delete_telegram()` (`/deleteMessage`) превью не порождает — правки не требует.
|
||||
|
||||
## 3. Бизнес-цель
|
||||
|
||||
Карточка трекера и уведомления в Telegram **не должны** показывать баннер link-preview Plane,
|
||||
при этом ссылка на задачу **остаётся кликабельной**.
|
||||
|
||||
## 4. Бизнес-требования
|
||||
|
||||
- **BR-1.** В payload `sendMessage` (`send_telegram`) присутствует `disable_web_page_preview: True`.
|
||||
- **BR-2.** В payload `editMessageText` (`edit_telegram`) присутствует `disable_web_page_preview: True`.
|
||||
- **BR-3.** Баннер-превью Plane больше не появляется ни под карточкой трекера (оба режима
|
||||
`bump`/`edit`), ни под отдельными notify-сообщениями, которые идут через `send_telegram`
|
||||
(`notify_approve_requested`, `notify_error`, alert'ы стадий) — все они используют тот же
|
||||
низкоуровневый примитив.
|
||||
- **BR-4.** Кликабельная ссылка `<a href>` на задачу в Plane сохраняется (`parse_mode: HTML`
|
||||
не меняется).
|
||||
- **BR-5.** Контракт **never-raise** сохранён: отправка/редактирование никогда не валит
|
||||
оркестратор; `pytest` зелёный.
|
||||
|
||||
## 5. Не-цели (вне скоупа)
|
||||
|
||||
- Не менять текст/формат/верстку карточки.
|
||||
- Не трогать `parse_mode` (HTML нужен для `<a href>`).
|
||||
- Не трогать bump/edit-логику (`update_task_tracker`), репойнт `tracker_message_id`,
|
||||
delete-семантику.
|
||||
- Не вводить флаги/конфиг — поведение «без превью» безусловное (превью никому не нужно).
|
||||
- Не трогать схему БД.
|
||||
|
||||
## 6. Заинтересованные лица
|
||||
|
||||
- **Слава (Owner)** — инициатор, конечный наблюдатель ленты Telegram.
|
||||
|
||||
## 7. Грабли / координация
|
||||
|
||||
- Файл `src/notifications.py` затрагивает также ORCH-067 (и потенциально другие задачи эпика).
|
||||
Сверить, что правки (две строки) не конфликтуют при merge.
|
||||
- Один репозиторий с ORCH-74 → по ORCH-026 действует сериализация merge.
|
||||
Запускать **после** того как ORCH-74 доедет в `main` (или когда конвейер свободен),
|
||||
чтобы не плодить параллельный merge в `orchestrator`.
|
||||
- Деплой — штатный через **Confirm Deploy** (self-hosting, ORCH-059).
|
||||
102
docs/work-items/ORCH-080/02-trz.md
Normal file
102
docs/work-items/ORCH-080/02-trz.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 02-TRZ — ORCH-080: убрать Telegram link-preview в уведомлениях трекера
|
||||
|
||||
Work Item ID: ORCH-080
|
||||
Зона изменений: `src/notifications.py` (две строки)
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
- `src/notifications.py` — **единственный** изменяемый модуль:
|
||||
- `send_telegram(text, disable_notification=False)` — обёртка `POST .../sendMessage`.
|
||||
- `edit_telegram(message_id, text)` — обёртка `POST .../editMessageText`.
|
||||
|
||||
Косвенно затронуты (поведение улучшается без изменения их кода — они вызывают изменённые
|
||||
примитивы): `update_task_tracker` (bump+edit), `notify_approve_requested`, `notify_error`,
|
||||
а также вызовы `send_telegram` из `launcher`/`stage_engine` (alert'ы деплоя/падений).
|
||||
|
||||
## 2. Изменения кода
|
||||
|
||||
### 2.1. `send_telegram()` — добавить ключ в JSON-payload `httpx.post`
|
||||
|
||||
В словаре `json={...}` вызова `sendMessage` (текущие стр. 55-60) добавить строку:
|
||||
|
||||
```python
|
||||
"disable_web_page_preview": True,
|
||||
```
|
||||
|
||||
Итоговый payload:
|
||||
```python
|
||||
json={
|
||||
"chat_id": s.telegram_chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_notification": disable_notification,
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
```
|
||||
|
||||
### 2.2. `edit_telegram()` — добавить ключ в JSON-payload `httpx.post`
|
||||
|
||||
В словаре `json={...}` вызова `editMessageText` (текущие стр. 168-173) добавить строку:
|
||||
|
||||
```python
|
||||
"disable_web_page_preview": True,
|
||||
```
|
||||
|
||||
Итоговый payload:
|
||||
```python
|
||||
json={
|
||||
"chat_id": s.telegram_chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
```
|
||||
|
||||
> Примечание: Telegram Bot API исторически принимает top-level `disable_web_page_preview`
|
||||
> для `sendMessage`/`editMessageText` (актуальная схема также поддерживает
|
||||
> `link_preview_options.is_disabled`, но top-level флаг остаётся валиден и совместим).
|
||||
> Используем top-level флаг — минимальная, обратносовместимая правка, как указано в задаче.
|
||||
|
||||
## 3. Изменения API
|
||||
|
||||
Нет изменений внутреннего HTTP API оркестратора. Меняется только тело исходящих запросов к
|
||||
Telegram Bot API (добавлен один булев ключ в payload двух методов).
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
|
||||
Нет.
|
||||
|
||||
## 5. Требования к новым QG checks
|
||||
|
||||
Нет. Новые Quality Gate проверки не вводятся.
|
||||
|
||||
## 6. Конфиг / флаги
|
||||
|
||||
Нет. Поведение «без превью» — безусловное (kill-switch не требуется: превью трекера
|
||||
не нужно никому, риск регрессии нулевой; правка обратимая одной строкой).
|
||||
`parse_mode`, `disable_notification`, bump/edit-логика — без изменений.
|
||||
|
||||
## 7. Артефакты, обновляемые по pipeline
|
||||
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]` (тип `fix:` — косметика UX уведомлений).
|
||||
- Документация: правка `src/notifications.py` затрагивает поведение, описанное в
|
||||
`CLAUDE.md` (раздел «Нотификации / Telegram live-tracker») и
|
||||
`docs/architecture/README.md` (компонент Notifications). Достаточно короткой ремарки,
|
||||
что карточка/уведомления шлются без web-page-preview (по желанию архитектора — определить
|
||||
объём в ADR; ADR не обязателен для столь малой косметики, решение за архитектором).
|
||||
|
||||
## 8. Контракты-инварианты (не нарушать)
|
||||
|
||||
- **never-raise**: обе функции по-прежнему ловят все исключения (`try/except: pass`/`return`)
|
||||
и не валят оркестратор.
|
||||
- Возвращаемые значения не меняются: `send_telegram` → `message_id|None`,
|
||||
`edit_telegram` → `EDIT_*`.
|
||||
- `parse_mode: "HTML"` сохранён в обоих payload (иначе `<a href>` сломается).
|
||||
- `disable_notification` в `send_telegram` сохранён (карточка тихая).
|
||||
- Инвариант «одна карточка на задачу» (bump/edit) не затрагивается.
|
||||
|
||||
## 9. Commit / ветка
|
||||
|
||||
- Ветка: `feature/ORCH-080-orch-52g-telegram-link-preview` (существует).
|
||||
- Commit: `fix: disable Telegram link-preview in tracker notifications (ORCH-080)`.
|
||||
59
docs/work-items/ORCH-080/03-acceptance-criteria.md
Normal file
59
docs/work-items/ORCH-080/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 03-Acceptance Criteria — ORCH-080
|
||||
|
||||
Work Item ID: ORCH-080
|
||||
|
||||
Каждый критерий имеет явное условие PASS/FAIL.
|
||||
|
||||
## AC-1 — `disable_web_page_preview` в payload `sendMessage`
|
||||
|
||||
- **PASS:** JSON-payload вызова `httpx.post(.../sendMessage)` в `send_telegram()` содержит
|
||||
ключ `"disable_web_page_preview"` со значением `True`.
|
||||
- **FAIL:** ключ отсутствует или `False`.
|
||||
- **Проверка:** unit-тест (мок `httpx`) инспектирует `httpx.post.call_args.kwargs["json"]`.
|
||||
|
||||
## AC-2 — `disable_web_page_preview` в payload `editMessageText`
|
||||
|
||||
- **PASS:** JSON-payload вызова `httpx.post(.../editMessageText)` в `edit_telegram()` содержит
|
||||
ключ `"disable_web_page_preview"` со значением `True`.
|
||||
- **FAIL:** ключ отсутствует или `False`.
|
||||
- **Проверка:** unit-тест (мок `httpx`) инспектирует `httpx.post.call_args.kwargs["json"]`.
|
||||
|
||||
## AC-3 — баннер link-preview Plane исчез в карточке трекера
|
||||
|
||||
- **PASS:** в реальном чате Telegram карточка трекера задачи (режимы `bump` и `edit`)
|
||||
больше не показывает баннер «Plane — Modern project management».
|
||||
- **FAIL:** баннер всё ещё разворачивается.
|
||||
- **Проверка:** ручная верификация на staging (8501) после деплоя — наблюдение карточки в
|
||||
Telegram. Автоматически косвенно покрыто AC-1/AC-2 (payload содержит флаг).
|
||||
|
||||
## AC-4 — ссылка на задачу остаётся кликабельной
|
||||
|
||||
- **PASS:** в карточке/уведомлениях номер задачи `ORCH-NNN` остаётся кликабельной ссылкой
|
||||
`<a href=...>` на issue в Plane; `parse_mode: "HTML"` сохранён в обоих payload.
|
||||
- **FAIL:** `parse_mode` изменён/удалён, либо ссылка перестала рендериться как `<a href>`.
|
||||
- **Проверка:** unit-тест проверяет, что `"parse_mode": "HTML"` присутствует в обоих payload;
|
||||
существующие тесты ссылок (`test_notify_issue_links.py`) остаются зелёными.
|
||||
|
||||
## AC-5 — сохранены существующие поля payload
|
||||
|
||||
- **PASS:** `send_telegram` payload по-прежнему содержит `chat_id`, `text`, `parse_mode`,
|
||||
`disable_notification`; `edit_telegram` payload — `chat_id`, `message_id`, `text`,
|
||||
`parse_mode`. Возвращаемые значения функций не изменились
|
||||
(`send_telegram → message_id|None`, `edit_telegram → EDIT_*`).
|
||||
- **FAIL:** любое из перечисленных полей удалено/переименовано, либо изменился контракт
|
||||
возврата.
|
||||
- **Проверка:** unit-тесты payload + существующие тесты трекера/классификации исходов.
|
||||
|
||||
## AC-6 — never-raise сохранён, pytest зелёный
|
||||
|
||||
- **PASS:** при сетевой/HTTP-ошибке `send_telegram`/`edit_telegram` не бросают исключение
|
||||
(возврат `None`/`EDIT_FAILED`); вся сюита `pytest tests/ -q` зелёная.
|
||||
- **FAIL:** любое исключение наружу или красный pytest.
|
||||
- **Проверка:** существующие тесты never-raise (`test_resilience.py`,
|
||||
`test_telegram_tracker.py`) + полный прогон.
|
||||
|
||||
## AC-7 — документация обновлена в том же PR
|
||||
|
||||
- **PASS:** `CHANGELOG.md` содержит запись об ORCH-080; при необходимости — короткая ремарка
|
||||
в `CLAUDE.md`/`docs/architecture/README.md` о подавлении link-preview.
|
||||
- **FAIL:** функционал изменён, документация не обновлена (Reviewer → REQUEST_CHANGES).
|
||||
76
docs/work-items/ORCH-080/04-test-plan.yaml
Normal file
76
docs/work-items/ORCH-080/04-test-plan.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
work_item: ORCH-080
|
||||
description: >
|
||||
Подавление Telegram link-preview (disable_web_page_preview: True) в payload
|
||||
send_telegram (sendMessage) и edit_telegram (editMessageText). Сохранить
|
||||
parse_mode HTML, disable_notification, never-raise и контракты возврата.
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: >
|
||||
send_telegram() кладёт "disable_web_page_preview": True в JSON-payload
|
||||
httpx.post(.../sendMessage). Проверка через мок httpx и инспекцию
|
||||
httpx.post.call_args.kwargs["json"].
|
||||
module: tests/test_link_preview_disabled.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: >
|
||||
edit_telegram() кладёт "disable_web_page_preview": True в JSON-payload
|
||||
httpx.post(.../editMessageText). Проверка через мок httpx и инспекцию
|
||||
payload.
|
||||
module: tests/test_link_preview_disabled.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
Регрессия parse_mode: оба payload (sendMessage и editMessageText)
|
||||
по-прежнему содержат "parse_mode": "HTML" — ссылка <a href> остаётся
|
||||
кликабельной (AC-4).
|
||||
module: tests/test_link_preview_disabled.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
Регрессия полей send_telegram: payload содержит chat_id, text,
|
||||
parse_mode, disable_notification; disable_notification прокидывается
|
||||
из аргумента (True/False) без изменений (AC-5).
|
||||
module: tests/test_link_preview_disabled.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
Контракты возврата не изменились: send_telegram возвращает message_id
|
||||
при ok:true, None при отсутствии креденшелов/ошибке; edit_telegram
|
||||
возвращает EDIT_OK при ok:true (AC-5, AC-6).
|
||||
module: tests/test_link_preview_disabled.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: >
|
||||
never-raise: при httpx.post бросающем исключение send_telegram->None и
|
||||
edit_telegram->EDIT_FAILED, без проброса исключения (AC-6).
|
||||
module: tests/test_link_preview_disabled.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: >
|
||||
Полный прогон существующей сюиты трекера/уведомлений остаётся зелёным
|
||||
(нет регрессий bump/edit-логики, классификации исходов, ссылок):
|
||||
pytest tests/test_telegram_tracker.py tests/test_tracker_bump.py
|
||||
tests/test_notify_issue_links.py tests/test_resilience.py.
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: integration
|
||||
description: >
|
||||
Вся сюита pytest tests/ -q зелёная (общая регрессия, AC-6).
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,63 @@
|
||||
# ADR-001: Подавление Telegram link-preview в низкоуровневых примитивах нотификаций
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
С ORCH-067 карточка трекера и notify-сообщения несут кликабельный номер задачи
|
||||
`<a href="https://plane.mva154.duckdns.org/.../issues/<id>/">ORCH-NNN</a>`. Telegram
|
||||
Bot API по умолчанию (при отсутствии ключа `disable_web_page_preview`) разворачивает
|
||||
web-page-preview для первой ссылки в сообщении — под каждым сообщением трекера
|
||||
раскрывается баннер «Plane — Modern project management». В дефолтном режиме `bump`
|
||||
(ORCH-067) карточка пересоздаётся на каждом переходе, поэтому баннер дублируется на
|
||||
каждой задаче и каждом обновлении, засоряя ленту (жалоба Owner, 08.06).
|
||||
|
||||
Код-аудит (`src/notifications.py`) подтвердил причину: JSON-payload обоих
|
||||
низкоуровневых примитивов — `send_telegram()` (`POST /sendMessage`, стр. 55-60) и
|
||||
`edit_telegram()` (`POST /editMessageText`, стр. 168-173) — **не содержит** ключ
|
||||
`disable_web_page_preview`. Все вышестоящие нотификации (`update_task_tracker` в обоих
|
||||
режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из
|
||||
`launcher`/`stage_engine`) проходят через эти два примитива.
|
||||
|
||||
## Решение
|
||||
Добавить `"disable_web_page_preview": True` в JSON-payload `httpx.post` обоих примитивов:
|
||||
`send_telegram()` и `edit_telegram()`. Изменение — **на уровне низкоуровневого
|
||||
примитива**, а не на уровне каждого вызова, потому что:
|
||||
|
||||
1. **Единая точка** — все исходящие сообщения трекера/нотификаций идут через эти две
|
||||
функции; правка двух строк гасит баннер у ВСЕХ потребителей (карточка `bump`/`edit`,
|
||||
notify-хелперы, alert'ы) без изменения их кода.
|
||||
2. **Безусловно, без флага** — превью Plane не нужно никому (это не данные, а навигация
|
||||
по ссылке, которая остаётся кликабельной). Kill-switch не вводится: риск регрессии
|
||||
нулевой, правка обратима одной строкой. Это согласуется с принципом «минимум
|
||||
зависимостей/конфигурации».
|
||||
3. **Top-level флаг, а не `link_preview_options.is_disabled`** — top-level
|
||||
`disable_web_page_preview` остаётся валиден и обратносовместим в Bot API; это
|
||||
минимальная правка без введения вложенной структуры.
|
||||
|
||||
`parse_mode: "HTML"` сохраняется в обоих payload (иначе `<a href>` перестанет
|
||||
рендериться — ссылка должна остаться кликабельной). `disable_notification`,
|
||||
bump/edit-логика, repoint `tracker_message_id`, delete-семантика, контракты возврата
|
||||
(`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) — не затрагиваются.
|
||||
|
||||
## Последствия
|
||||
**Плюсы:**
|
||||
- Баннер link-preview исчезает под карточкой трекера (оба режима) и под всеми
|
||||
notify/alert-сообщениями — одна правда в двух примитивах.
|
||||
- Ссылка на задачу остаётся кликабельной (HTML сохранён).
|
||||
- Нулевой риск: ключ аддитивный, контракты примитивов и инвариант «одна карточка на
|
||||
задачу» не меняются; `never-raise` (`try/except`) сохранён.
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- Поведение безусловное — нет конфигурации «вернуть превью». Сознательный выбор:
|
||||
превью трекера не имеет ценности, флаг был бы лишней поверхностью.
|
||||
|
||||
**Не затрагивается:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `parse_mode`,
|
||||
`disable_notification`, транспортные хелперы `delete_telegram`/repoint-логика. Глобальный
|
||||
ADR не требуется — решение локально для `src/notifications.py`, не сквозное.
|
||||
|
||||
## Self-hosting
|
||||
Изменение не требует немедленного рестарта прод-контейнера и не меняет топологию.
|
||||
Деплой — штатный через staging (8501) → `Confirm Deploy` (ORCH-059). По ORCH-026
|
||||
(сериализация merge одного репо) задача мержится после освобождения конвейера
|
||||
`orchestrator` (координация с ORCH-074 — см. BRD §7).
|
||||
22
docs/work-items/ORCH-080/10-tech-risks.md
Normal file
22
docs/work-items/ORCH-080/10-tech-risks.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 10-Tech Risks — ORCH-080
|
||||
|
||||
Work Item ID: ORCH-080
|
||||
Зона: `src/notifications.py` (две строки в `send_telegram`/`edit_telegram`)
|
||||
|
||||
Косметическая правка UX (LOW). Топология, схема БД, стадии, QG — не меняются.
|
||||
Риск регрессии оценён как **нулевой**; ниже — остаточные пункты для внимания.
|
||||
|
||||
| # | Риск | Вероятность | Влияние | Митигация |
|
||||
|---|------|-------------|---------|-----------|
|
||||
| R-1 | Опечатка ключа/значения (`disable_web_page_preview`) — баннер не гаснет | Низкая | Низкое (косметика) | unit-тест AC-1/AC-2 инспектирует `httpx.post.call_args.kwargs["json"]`; ручная верификация на staging (AC-3) |
|
||||
| R-2 | Случайное удаление `parse_mode: "HTML"` → ссылка `<a href>` ломается | Очень низкая | Среднее (теряется кликабельность) | AC-4: unit-тест на наличие `parse_mode: "HTML"` в обоих payload; `test_notify_issue_links.py` остаётся зелёным |
|
||||
| R-3 | Merge-конфликт с ORCH-067/ORCH-074 в `src/notifications.py` | Низкая | Низкое | По ORCH-026 сериализация merge одного репо; запуск после доезда ORCH-74 в `main` (BRD §7); pre-merge rebase (ORCH-043) |
|
||||
| R-4 | Регрессия контракта возврата примитивов (`message_id|None` / `EDIT_*`) | Очень низкая | Среднее | Правка строго аддитивна (новый ключ в payload), возвраты не трогаются; AC-5 + существующие тесты трекера |
|
||||
| R-5 | Telegram депрекейтит top-level `disable_web_page_preview` в пользу `link_preview_options` | Очень низкая | Низкое (forward-compat) | Top-level флаг остаётся валиден и обратносовместим; миграция на `link_preview_options.is_disabled` — отдельная задача при необходимости |
|
||||
|
||||
## Инварианты, которые НЕЛЬЗЯ нарушить
|
||||
- `never-raise` обоих примитивов (`try/except` сохранён).
|
||||
- `parse_mode: "HTML"` в обоих payload (иначе `<a href>` ломается).
|
||||
- `disable_notification` в `send_telegram` (карточка тихая).
|
||||
- Инвариант «одна карточка на задачу» (bump/edit) — не затрагивается.
|
||||
- Контракты возврата: `send_telegram → message_id|None`, `edit_telegram → EDIT_*`.
|
||||
72
docs/work-items/ORCH-080/12-review.md
Normal file
72
docs/work-items/ORCH-080/12-review.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-080
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-080
|
||||
|
||||
## Summary
|
||||
Задача убирает баннер Telegram link-preview («Plane — Modern project management»),
|
||||
который разворачивался под кликабельной ссылкой `ORCH-NNN` в карточке трекера и
|
||||
во всех notify/alert-сообщениях. Решение точно соответствует TRZ и ADR-001:
|
||||
добавлен ключ `"disable_web_page_preview": True` в JSON-payload обоих
|
||||
низкоуровневых примитивов `send_telegram` (`POST /sendMessage`) и `edit_telegram`
|
||||
(`POST /editMessageText`) — единая точка для всех потребителей, без kill-switch,
|
||||
без изменения контрактов. Изменение минимально (2 строки + комментарии),
|
||||
аддитивно и обратимо.
|
||||
|
||||
Проверены все четыре оси (ТЗ, ADR, качество кода, тесты) + документация. Findings
|
||||
уровней P0/P1/P2 — нет.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
## Соответствие ТЗ и AC
|
||||
- TRZ §2.1/§2.2 — ключ добавлен в оба payload в точности как предписано. ✅
|
||||
- AC-1 — `disable_web_page_preview: True` в `sendMessage` payload (TC-01). ✅
|
||||
- AC-2 — то же в `editMessageText` payload (TC-02). ✅
|
||||
- AC-3 — баннер исчезает (ручная верификация на staging; косвенно покрыто AC-1/AC-2). ✅
|
||||
- AC-4 — `parse_mode: "HTML"` сохранён в обоих payload, ссылка кликабельна (TC-03);
|
||||
`tests/test_notify_issue_links.py` зелёный. ✅
|
||||
- AC-5 — поля `chat_id/text/parse_mode/disable_notification` (send) и
|
||||
`chat_id/message_id/text/parse_mode` (edit) сохранены; контракты возврата
|
||||
(`message_id|None`, `EDIT_*`) не изменились (TC-04/TC-05). ✅
|
||||
- AC-6 — never-raise сохранён (TC-06); полный прогон `pytest tests/ -q` — **1058 passed**. ✅
|
||||
- AC-7 — документация обновлена в том же PR (см. ниже). ✅
|
||||
|
||||
## Соответствие ADR
|
||||
ADR-001 (Accepted): правка на уровне примитива (а не каждого вызова), безусловно
|
||||
без флага, top-level `disable_web_page_preview` вместо `link_preview_options`,
|
||||
`parse_mode: HTML` сохранён, контракты и инвариант «одна карточка на задачу» не
|
||||
тронуты. Реализация соответствует решению 1:1. Глобальные ADR не нарушены
|
||||
(`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений). ✅
|
||||
|
||||
## Качество кода
|
||||
- Изменение минимальное, целевое; комментарии ссылаются на ORCH-080 и поясняют цель.
|
||||
- `try/except` never-raise в обеих функциях не затронут; пути без кредов и контракты
|
||||
возврата сохранены.
|
||||
- Тесты содержательные: инспектируют реальный payload через мок `httpx`
|
||||
(`call_args.kwargs["json"]`), покрывают флаг, регрессию `parse_mode`/полей,
|
||||
контракты возврата и never-raise (TC-01..06). Нет тривиальных/пустых тестов.
|
||||
- Security: ключ булев, новых поверхностей/секретов нет.
|
||||
|
||||
## Документация
|
||||
Изменён `src/` (поведение исходящих Telegram-запросов) → документация обновлена в
|
||||
том же PR, как требует CLAUDE.md §2/§6:
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]` (тип `fix:`). ✅
|
||||
- `CLAUDE.md` — раздел «Нотификации / Telegram live-tracker» дополнен пунктом
|
||||
«Без link-preview (ORCH-080)». ✅
|
||||
- `docs/architecture/README.md` — компонент Notifications дополнен ремаркой ORCH-080. ✅
|
||||
- ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md` заведён. ✅
|
||||
|
||||
Документация соответствует коду; расхождений нет.
|
||||
66
docs/work-items/ORCH-080/13-test-report.md
Normal file
66
docs/work-items/ORCH-080/13-test-report.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-080
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-080
|
||||
|
||||
Подавление Telegram link-preview (`disable_web_page_preview: True`) в `send_telegram`
|
||||
(`sendMessage`) и `edit_telegram` (`editMessageText`). Сохранены `parse_mode: HTML`,
|
||||
`disable_notification`, never-raise и контракты возврата.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Дата: 2026-06-09
|
||||
- Ветка: `feature/ORCH-080-orch-52g-telegram-link-preview`
|
||||
- Review verdict: APPROVED (`12-review.md`)
|
||||
|
||||
## Smoke test API (prod 8500, read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
|
||||
| `GET /status` | OK (ORCH-080 = task #62, stage `testing`) |
|
||||
| `GET /queue` | OK (breaker `closed`, preflight_ok, reconcile/reaper enabled) |
|
||||
|
||||
## Результаты тестов
|
||||
|
||||
| TC ID | Описание | Тест(ы) | Результат |
|
||||
|-------|----------|---------|-----------|
|
||||
| TC-01 | `disable_web_page_preview: True` в payload `sendMessage` (AC-1) | `test_send_telegram_disables_link_preview` | PASS |
|
||||
| TC-02 | `disable_web_page_preview: True` в payload `editMessageText` (AC-2) | `test_edit_telegram_disables_link_preview` | PASS |
|
||||
| TC-03 | Регрессия `parse_mode: HTML` в обоих payload (AC-4) | `test_send_telegram_keeps_parse_mode_html`, `test_edit_telegram_keeps_parse_mode_html` | PASS |
|
||||
| TC-04 | Регрессия полей `send_telegram` + проброс `disable_notification` (AC-5) | `test_send_telegram_preserves_existing_fields`, `test_send_telegram_disable_notification_default_false`, `test_edit_telegram_preserves_existing_fields` | PASS |
|
||||
| TC-05 | Контракты возврата (`message_id`/`None`/`EDIT_OK`) (AC-5/AC-6) | `test_send_telegram_returns_message_id`, `test_send_telegram_returns_none_without_creds`, `test_edit_telegram_returns_edit_ok` | PASS |
|
||||
| TC-06 | never-raise → `None`/`EDIT_FAILED` без проброса (AC-6) | `test_send_telegram_never_raises`, `test_edit_telegram_never_raises` | PASS |
|
||||
| TC-07 | Регресс сюиты трекера/уведомлений (bump/edit, ссылки, resilience) | `test_telegram_tracker.py`, `test_tracker_bump.py`, `test_notify_issue_links.py`, `test_resilience.py` (+ `test_link_preview_disabled.py`) — 106 passed | PASS |
|
||||
| TC-08 | Полная регрессия `pytest tests/ -q` (AC-6) | вся сюита — 1058 passed | PASS |
|
||||
|
||||
## Покрытие Acceptance Criteria
|
||||
- AC-1 — TC-01 ✅
|
||||
- AC-2 — TC-02 ✅
|
||||
- AC-3 (баннер исчез в чате) — ручная верификация на staging (8501) после деплоя; автоматически косвенно покрыто AC-1/AC-2 (payload несёт флаг). Не блокирует тест-гейт.
|
||||
- AC-4 — TC-03 + `test_notify_issue_links.py` зелёный ✅
|
||||
- AC-5 — TC-04/TC-05 ✅
|
||||
- AC-6 — TC-06 + полный прогон зелёный ✅
|
||||
- AC-7 — документация (CHANGELOG/CLAUDE.md/architecture/ADR) проверена на review-стадии ✅
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полная сюита:
|
||||
```
|
||||
1058 passed, 1 warning in 26.61s
|
||||
```
|
||||
|
||||
Целевые файлы ORCH-080 (TC-01..07):
|
||||
```
|
||||
106 passed, 1 warning in 3.24s
|
||||
```
|
||||
(`test_link_preview_disabled.py` — 12 passed.)
|
||||
|
||||
Единственный warning — `PydanticDeprecatedSince20` в `src/config.py:5` (предсуществующий, не связан с ORCH-080).
|
||||
|
||||
## Итог
|
||||
**PASS** — все автоматические тесты (TC-01..08) зелёные, smoke API OK, регрессий нет.
|
||||
Задача готова к переходу на стадию `deploy-staging`.
|
||||
12
docs/work-items/ORCH-080/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-080/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-080
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
26
docs/work-items/ORCH-080/15-staging-log.md
Normal file
26
docs/work-items/ORCH-080/15-staging-log.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T22:31:47Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
|
||||
Run canonically **inside** the container via the Docker exec API (REST equivalent of
|
||||
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
|
||||
--base-url http://localhost:8501 --mode stub`), so B6 reads the staging instance's own
|
||||
process-env registry (ORCH-048, ADR-001).
|
||||
|
||||
**Exit code: 0 → advance.** All REAL pipeline checks passed (8/10 PASS).
|
||||
|
||||
- Block A (SMOKE): A1 /health, A2 /queue, A3 ORCH_STAGING=true — PASS
|
||||
- Block B (ACCESS): B4 Plane sandbox, B5 Gitea sandbox (push=true), B6 registry isolation
|
||||
(sandbox present, prod ET/ORCH absent) — PASS
|
||||
- Block C (E2E): C7 create issue in SANDBOX, C8 trigger pipeline via /webhook/plane — PASS
|
||||
- C9a/C9b — FAILED but **waived** (known sandbox-infra checks; depend on SANDBOX bot
|
||||
accounts being project members, not on the pipeline). Tolerated under ORCH-061 because
|
||||
every REAL check is 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
|
||||
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.
|
||||
7
docs/work-items/ORCH-082/00-business-request.md
Normal file
7
docs/work-items/ORCH-082/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-81: конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD)
|
||||
|
||||
Work Item ID: ORCH-082
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
119
docs/work-items/ORCH-082/01-brd.md
Normal file
119
docs/work-items/ORCH-082/01-brd.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 01 — BRD: ORCH-082 (ORCH-81)
|
||||
|
||||
**Конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD)**
|
||||
|
||||
- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81»)
|
||||
- Repo: `orchestrator` (self-hosting)
|
||||
- Тип: **Багфикс / надёжность конвейера**
|
||||
- Приоритет: **HIGH** — блокирует автономный деплой
|
||||
- Зона: создание PR (reviewer/developer/deployer пути), `src/merge_gate.py`, `src/stage_engine.py` (`_handle_merge_verify`), `src/agents/launcher.py` (`_ensure_pr`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
При деплое **ORCH-074** (08.06, статус «Confirm Deploy») детерминированный finalizer
|
||||
(`run_deploy_finalizer` → под-гейт `_handle_merge_verify`) вызвал
|
||||
`merge_gate.merge_pr(repo, branch)` и получил **`ok=False` («no open PR»)**: в Gitea для
|
||||
ветки `feature/ORCH-074-…` **не существовало открытого PR** с `head.ref==branch` и
|
||||
`base.ref=="main"`.
|
||||
|
||||
Защита **ORCH-073** (fail-closed по «SHA-в-main») отработала **корректно**: задача удержана
|
||||
на стадии `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert, ложно-зелёного `done` не
|
||||
произошло. Это **правильное** поведение для случая «merge реально невозможен».
|
||||
|
||||
**Дефект не в защите, а в инварианте до неё:** автономный конвейер **не гарантировал**, что к
|
||||
моменту merge у ветки существует открытый PR. PR на сегодня создаётся ровно в одном месте —
|
||||
`launcher._ensure_pr`, вызываемом **только** на пути `agent == "developer"` и **только** когда
|
||||
в этом конкретном run был непустой git-diff, успешный commit и успешный push (см. root-cause
|
||||
ниже). Любой сценарий, где developer-run не произвёл свежий коммит, оставляет ветку **без PR**,
|
||||
и задача неминуемо застревает на merge-verify.
|
||||
|
||||
### Workaround, применённый вручную (НЕ фикс)
|
||||
PR #79 создан вручную через Gitea API (`mergeable=True`) → штатно перезапущен
|
||||
`run_deploy_finalizer` → `merge_pr` честно влил код в `main` → задача `done`. Это разовое ручное
|
||||
вмешательство, **не** устранение причины.
|
||||
|
||||
### Почему это системный пробел, а не разовый сбой
|
||||
Так как создание PR **не гарантировано конвейером**, любая следующая задача с тем же стечением
|
||||
обстоятельств (developer-run без нового коммита; тихо упавший вызов создания PR; ветка
|
||||
восстановлена/пересоздана вручную) застрянет на merge-verify тем же образом. Автономность
|
||||
деплоя (цель ORCH-54) этим заблокирована.
|
||||
|
||||
---
|
||||
|
||||
## 2. Root cause (предварительный аудит кода — подтвердить логами G1)
|
||||
|
||||
PR создаётся **исключительно** функцией `AgentLauncher._ensure_pr` (`src/agents/launcher.py`),
|
||||
которая вызывается из `_monitor_agent` по цепочке условий:
|
||||
|
||||
```
|
||||
exit_code == 0
|
||||
→ есть worktree-изменения (git status --porcelain непусто)
|
||||
→ git commit succeeded
|
||||
→ git push succeeded
|
||||
→ agent == "developer" ←── ТОЛЬКО здесь вызывается self._ensure_pr(...)
|
||||
```
|
||||
|
||||
Отсюда минимум три структурных способа остаться без PR:
|
||||
|
||||
- **R-A (условное создание).** Если developer-run завершился без изменений (`git status`
|
||||
пустой) — ветка уже была закоммичена/запушена в прошлый run, бойнс REQUEST_CHANGES без новых
|
||||
правок, повторный прогон, или ручное восстановление ветки — `_ensure_pr` **не вызывается
|
||||
вовсе**. PR не появится никогда. (Соответствует гипотезе ТЗ №2.)
|
||||
- **R-B (тихий сбой создания).** `_ensure_pr` ловит любое исключение
|
||||
(`except Exception → logger.error → return None`): транзиентная ошибка Gitea на шаге
|
||||
`POST …/pulls` теряется без ретрая и без эскалации. Конвейер «думает», что developer
|
||||
отработал, и едет дальше. (Гипотеза ТЗ №1 — silent fail.)
|
||||
- **R-C (разъехавшееся состояние ветки/PR).** ORCH-074 — первая задача после серии ручных
|
||||
восстановлений `main` 08.06. PR мог быть закрыт/пересоздан, либо у ветки остался только
|
||||
авто-docs-PR (`base != main`), который `merge_pr`/`pr_already_merged` корректно НЕ считают
|
||||
кодовым PR. (Гипотеза ТЗ №4.)
|
||||
|
||||
Идемпотентность (гипотеза №3): сам `_ensure_pr` идемпотентен на чтении (сначала `GET …open&head`,
|
||||
создаёт только если пусто), но он не запускается вне «свежий developer-коммит», поэтому
|
||||
идемпотентность не достигает merge-стадии — никакой флаг «PR создан» в БД не хранится.
|
||||
|
||||
**Вывод:** гарантия «к моменту merge у ветки есть открытый код-PR» в конвейере **отсутствует**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Бизнес-цели
|
||||
|
||||
| ID | Цель |
|
||||
|----|------|
|
||||
| **G1** | Установить и задокументировать точную причину отсутствия PR на ORCH-074 (код-аудит + логи run_id 396/398). |
|
||||
| **G2** | Гарантировать инвариант: к моменту merge-verify у ветки **есть** открытый код-PR; если его нет — finalizer/deployer создаёт его сам, **идемпотентно**, ПЕРЕД `merge_pr`, вместо HOLD на ручное вмешательство. |
|
||||
| **G3** | Явно логировать факт PR: **PR-created / PR-existed / PR-create-failed** (наблюдаемость). |
|
||||
|
||||
## 4. Не-цели (явные границы)
|
||||
|
||||
- НЕ ослаблять защиту ORCH-073: fail-closed по «SHA-в-main» остаётся. Реальная невозможность
|
||||
merge → по-прежнему HOLD + alert.
|
||||
- НЕ авто-мержить без PR (PR — обязательный артефакт ревью/слияния).
|
||||
- НЕ создавать PR в неподходящий момент — только на ребре `deploy → done`, ПОСЛЕ прохождения
|
||||
всех гейтов (security/merge-gate/staging/image-freshness уже пройдены).
|
||||
- НЕ менять `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схему БД, контракты `check_deploy_status`,
|
||||
exit-коды хука.
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
- **Owner** (homenet542) — автономность деплоя орка.
|
||||
- Все проекты на инстансе (enduro-trails) — общий прод/очередь: ложный HOLD self-задачи не
|
||||
должен требовать ручного вмешательства, а реальный дефект merge — обязан удерживаться.
|
||||
|
||||
## 6. Бизнес-риски и допущения
|
||||
- **Грабли (из ORCH-073):** у ветки может быть несколько PR (код-PR + авто docs-PR). Создание/
|
||||
выбор PR обязан фильтровать `head.ref==branch` И `base.ref=="main"`, иначе слияние/верификация
|
||||
схватят не тот PR.
|
||||
- **Допущение:** merge-verify исполняется ПОСЛЕ всех гейтов, поэтому создание PR именно здесь не
|
||||
обходит ревью и безопасно по времени.
|
||||
- **Контракт надёжности:** весь новый путь — **never-raise**; ошибка создания PR (Gitea
|
||||
недоступна) → честный HOLD + alert, а не исключение в `advance_stage`.
|
||||
|
||||
## 7. Definition of Done (бизнес-уровень)
|
||||
1. Root cause задокументирован (`06-adr/` архитектором, ссылка из ADR на этот BRD).
|
||||
2. После фикса задача с веткой без PR не зависает: конвейер создаёт PR идемпотентно и доводит до
|
||||
`done` (при честном merge).
|
||||
3. Защита ORCH-073 цела (регресс-тест на «код не в main» → HOLD).
|
||||
4. Логи различают created/existed/failed.
|
||||
5. `pytest` зелёный; never-raise соблюдён.
|
||||
108
docs/work-items/ORCH-082/02-trz.md
Normal file
108
docs/work-items/ORCH-082/02-trz.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 02 — ТЗ: ORCH-082 (ORCH-81)
|
||||
|
||||
**Гарантированный идемпотентный код-PR перед merge-verify + наблюдаемость**
|
||||
|
||||
> Машина стадий, реестр `QG_CHECKS`, схема БД, exit-коды хука, контракты
|
||||
> `check_deploy_status`/`_parse_deploy_status`, защита ORCH-073 (SHA-в-main) — **НЕ меняются**.
|
||||
> Изменение — точечная врезка «ensure PR» в под-гейт merge-verify + новый идемпотентный
|
||||
> PR-актор в `merge_gate` + структурное логирование.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче | Характер изменения |
|
||||
|--------|---------------|--------------------|
|
||||
| `src/merge_gate.py` | leaf-логика merge-актора (`merge_pr`, `verify_merged_to_main`, `pr_already_merged`) | **+ новый идемпотентный актор** `ensure_open_pr(repo, branch) -> (status, detail)` (never-raise). |
|
||||
| `src/stage_engine.py` | под-гейт `_handle_merge_verify` на ребре `deploy → done` | **врезка:** вызвать `ensure_open_pr` ПЕРЕД `merge_pr`; на `failed` → честный HOLD+alert; логировать исход. |
|
||||
| `src/agents/launcher.py` | `_ensure_pr` (текущий единственный создатель PR) | **усилить наблюдаемость** (различать created/existed/failed) — опционально переиспользовать новый актор `merge_gate.ensure_open_pr`, чтобы создание PR было единым кодом. Поведение «создавать только у developer» НЕ ужесточать без необходимости. |
|
||||
| `src/config.py` | флаги | **+ kill-switch** `merge_verify_autocreate_pr_enabled` (дефолт `True`), область — та же `merge_verify_applies` (self-hosting / `merge_verify_repos`). |
|
||||
| `docs/architecture/README.md`, `CHANGELOG.md` | golden source | обновить (раздел ORCH-071/073 merge-verify — дописать про авто-создание PR). |
|
||||
|
||||
> Точная сигнатура `ensure_open_pr`, имя/дефолт kill-switch и место врезки — за архитектором
|
||||
> (ADR). Ниже — функциональные требования к поведению, не финальный дизайн.
|
||||
|
||||
## 2. Функциональные требования
|
||||
|
||||
### FR-1 — Идемпотентный PR-актор `merge_gate.ensure_open_pr(repo, branch)`
|
||||
Возвращает структурированный исход (например `("existed"|"created"|"failed", detail)`):
|
||||
1. `GET …/pulls?state=open` → если есть PR с **`head.ref==branch` И `base.ref=="main"`** →
|
||||
`("existed", <number>)`. **Фильтр идентичен `merge_pr`/ORCH-073 FR-3** — авто-docs-PR
|
||||
(`base != main`) НЕ считается код-PR.
|
||||
2. Иначе `POST …/pulls` (`head=branch`, `base=main`, заголовок/тело — авто) → `201` →
|
||||
`("created", <number>)`.
|
||||
3. Идемпотентность: если параллельно PR уже создан и Gitea вернёт ошибку «PR exists» —
|
||||
повторный `GET` подтверждает существующий PR и возвращает `("existed", …)`, **дубль не
|
||||
плодится** (AC-2).
|
||||
4. Любая иная ошибка HTTP/parse/сети → `("failed", <reason>)`. **Never-raise.**
|
||||
|
||||
### FR-2 — Врезка в `_handle_merge_verify` (ребро `deploy → done`)
|
||||
Внутри существующего `_handle_merge_verify`, ПОСЛЕ `merge_verify_applies(repo)`-гейта и
|
||||
резолва `validated_revision`, но **ПЕРЕД** `merge_pr`:
|
||||
- если `merge_verify_autocreate_pr_enabled` → вызвать `ensure_open_pr(repo, branch)`;
|
||||
- `status == "created"|"existed"` → продолжить штатно к `merge_pr` → `verify_merged_to_main`;
|
||||
- `status == "failed"` → **честный HOLD + alert** (как сегодняшний not-merged путь:
|
||||
`note_not_merged_alert` + `set_issue_blocked` + Plane-коммент + Telegram; задача остаётся на
|
||||
`deploy`, НЕ `done`, БЕЗ отката на development) с сообщением, отражающим «PR создать не
|
||||
удалось» (а не «PR не влит»).
|
||||
- kill-switch off → текущее поведение 1:1 (никакого создания PR).
|
||||
|
||||
### FR-3 — Защита ORCH-073 цела (регресс-инвариант)
|
||||
Создание PR **не подменяет** проверку слияния. После `ensure_open_pr` + `merge_pr` верификация
|
||||
остаётся **только** `verify_merged_to_main` (SHA-в-main, ORCH-073 FR-1) + регресс-гард
|
||||
(`check_main_regression`). Если код реально не оказался в `main` — HOLD сохраняется. Создание PR
|
||||
лишь устраняет **ложный** HOLD «no open PR», который конвейер обязан был предотвратить.
|
||||
|
||||
### FR-4 — Наблюдаемость (G3)
|
||||
В лог писать однозначный исход на каждом из мест работы с PR:
|
||||
- `merge-verify ensure_open_pr -> created PR #N` /
|
||||
- `… -> existed PR #N` /
|
||||
- `… -> failed: <reason>`.
|
||||
Сообщение HOLD при `failed` обязано отличаться текстом от HOLD «not merged» (оператор должен
|
||||
видеть, что причина — невозможность создать PR, а не невозможность слить уже созданный).
|
||||
Желательно — пометка исхода в `14-deploy-log.md` (best-effort, frontmatter `deploy_status:`
|
||||
нетронут).
|
||||
|
||||
### FR-5 — Идемпотентность повторного прохода
|
||||
Повторный заход в merge-verify (reaper / reconciler / повторный approve) при уже существующем
|
||||
PR → `ensure_open_pr` возвращает `("existed", …)`, `merge_pr` → `already-merged`/штатно — **без
|
||||
дублей PR и без побочных эффектов** (INV-5/AC-9 ORCH-073 сохранены).
|
||||
|
||||
## 3. Изменения API (HTTP / внутренние)
|
||||
- **Внешний HTTP API сервиса — без изменений** (новых endpoint нет).
|
||||
- **Исходящие вызовы Gitea:** новый `POST /api/v1/repos/{owner}/{repo}/pulls` из контекста
|
||||
merge-verify (тот же вызов, что уже делает `_ensure_pr`); чтение — существующий
|
||||
`GET …/pulls?state=open`.
|
||||
- **Внутренний контракт `merge_gate`:** новая публичная функция `ensure_open_pr` (leaf,
|
||||
never-raise), вызывается из `stage_engine._handle_merge_verify` (и опционально из
|
||||
`launcher._ensure_pr`).
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
**Нет.** Состояние идемпотентности выводится из самого Gitea (наличие открытого PR), миграции
|
||||
не требуются. (Согласуется с restart-safe-моделью merge-verify.)
|
||||
|
||||
## 5. Требования к новым QG checks
|
||||
**Новых зарегистрированных QG-checks нет.** Это под-гейт-врезка в `advance_stage`
|
||||
(`_handle_merge_verify`), как и сам ORCH-071 merge-verify — не отдельный `QG_CHECKS`-элемент.
|
||||
Реестр `QG_CHECKS` не трогается.
|
||||
|
||||
## 6. Конфигурация / kill-switch
|
||||
- `merge_verify_autocreate_pr_enabled: bool = True` (env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`).
|
||||
`False` → ровно прежнее поведение (нет авто-создания PR; «no open PR» → HOLD как раньше).
|
||||
- Область действия — `merge_gate.merge_verify_applies(repo)`: реально только для self-hosting /
|
||||
`merge_verify_repos`; прочие репо — no-op.
|
||||
|
||||
## 7. Артефакты pipeline (создать/обновить)
|
||||
- `docs/work-items/ORCH-082/06-adr/ADR-001-*.md` — архитектор (root cause G1 + дизайн ensure-PR).
|
||||
- `12-review.md`, `13-test-report.md`, `14/15/16-*` — последующие стадии.
|
||||
- Обновить `docs/architecture/README.md` (блок ORCH-071/073) и `CHANGELOG.md` — в ТОМ ЖЕ PR
|
||||
(правило агентов №2/№6).
|
||||
|
||||
## 8. Инварианты (не нарушать)
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`,
|
||||
exit-коды хука, terminal-sync, merge-gate (ORCH-043), image-freshness (ORCH-058) — **без
|
||||
изменений**.
|
||||
- Контракт **never-raise** на всём пути merge-verify (INV-1 ORCH-073).
|
||||
- Слияние только через PR (`POST /pulls/{index}/merge`); `main` никогда не push/force-push.
|
||||
- Защита ORCH-073 (SHA-в-main + регресс-гард) приоритетна: при конфликте «создать PR» проигрывает
|
||||
«не дать ложно-зелёный done».
|
||||
69
docs/work-items/ORCH-082/03-acceptance-criteria.md
Normal file
69
docs/work-items/ORCH-082/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 03 — Критерии приёмки: ORCH-082 (ORCH-81)
|
||||
|
||||
Каждый критерий — однозначное условие PASS/FAIL. Машинные вердикты гейтов — только из
|
||||
YAML-frontmatter.
|
||||
|
||||
---
|
||||
|
||||
### AC-1 — Root cause задокументирован
|
||||
- **PASS:** в `06-adr/ADR-001-*.md` зафиксировано, **почему** PR не создался на ORCH-074
|
||||
(со ссылкой на код-путь `launcher._ensure_pr` и/или логи run_id 396/398), и какая из гипотез
|
||||
R-A/R-B/R-C подтвердилась.
|
||||
- **FAIL:** причина не названа / только догадка без привязки к коду или логам.
|
||||
|
||||
### AC-2 — Гарантированный идемпотентный код-PR к merge-verify
|
||||
- **PASS:** к моменту merge-verify у ветки гарантированно существует открытый PR с
|
||||
`head.ref==branch` И `base.ref=="main"`; повторный вызов авто-создания при уже существующем PR
|
||||
**не плодит дубль** (возвращает existed).
|
||||
- **FAIL:** при отсутствии PR задача сразу уходит в HOLD; ИЛИ повторный проход создаёт второй PR.
|
||||
|
||||
### AC-3 — Авто-создание PR ПЕРЕД merge_pr (вместо немедленного HOLD)
|
||||
- **PASS:** при физическом отсутствии открытого код-PR `_handle_merge_verify` сначала создаёт PR
|
||||
(`ensure_open_pr → created`), затем выполняет `merge_pr` → `verify_merged_to_main`; ложного
|
||||
HOLD «no open PR» не возникает.
|
||||
- **FAIL:** «no open PR» по-прежнему приводит к HOLD без попытки создать PR (при включённом
|
||||
kill-switch).
|
||||
|
||||
### AC-4 — Защита ORCH-073 цела (регресс)
|
||||
- **PASS:** при реальном «код не в `main`» (`verify_merged_to_main → False`) — по-прежнему HOLD +
|
||||
alert + `set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на development. Регресс-гард
|
||||
`check_main_regression` не ослаблен.
|
||||
- **FAIL:** создание PR маскирует невлитый код и пропускает задачу в `done`; ИЛИ ослаблен
|
||||
SHA-в-main / регресс-гард.
|
||||
|
||||
### AC-5 — Логи различают исход PR
|
||||
- **PASS:** в логах присутствует ровно один однозначный исход на проход: **PR-created** /
|
||||
**PR-existed** / **PR-create-failed**; HOLD по «create-failed» текстуально отличим от HOLD
|
||||
«not merged».
|
||||
- **FAIL:** исход не логируется или created/existed/failed неразличимы.
|
||||
|
||||
### AC-6 — Грабли мультиPR: фильтр base==main
|
||||
- **PASS:** при наличии у ветки авто-docs-PR (`base != main`) актор НЕ принимает его за код-PR и
|
||||
создаёт/выбирает именно PR на `main`.
|
||||
- **FAIL:** docs-PR трактуется как код-PR (слияние/верификация работают не с тем PR).
|
||||
|
||||
### AC-7 — Never-raise + честный HOLD при недоступности Gitea
|
||||
- **PASS:** при ошибке создания PR (Gitea недоступна/HTTP-ошибка) `ensure_open_pr` возвращает
|
||||
`failed`, путь merge-verify даёт честный HOLD+alert, исключение НЕ всплывает в `advance_stage`.
|
||||
- **FAIL:** исключение пробрасывается / процесс падает / задача молча уходит в `done`.
|
||||
|
||||
### AC-8 — Kill-switch off → прежнее поведение 1:1
|
||||
- **PASS:** при `merge_verify_autocreate_pr_enabled=False` авто-создание не выполняется; «no open
|
||||
PR» → HOLD как до фикса (поведение ORCH-074 воспроизводится).
|
||||
- **FAIL:** при выключенном флаге PR всё равно создаётся.
|
||||
|
||||
### AC-9 — Условность (область self-hosting)
|
||||
- **PASS:** для не-self репозиториев (`merge_verify_applies → False`) врезка — no-op; создание PR
|
||||
остаётся за прежним механизмом.
|
||||
- **FAIL:** авто-создание срабатывает для чужих репо.
|
||||
|
||||
### AC-10 — Инварианты не нарушены
|
||||
- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`, exit-коды хука,
|
||||
merge-gate/image-freshness — без изменений; `main` не push/force-push; документация
|
||||
(`README.md`, `CHANGELOG.md`) обновлена в этом же PR.
|
||||
- **FAIL:** затронут любой из перечисленных инвариантов / документация не обновлена.
|
||||
|
||||
### AC-11 — pytest зелёный
|
||||
- **PASS:** `pytest tests/ -q` зелёный, включая новые тесты из `04-test-plan.yaml` и
|
||||
существующие `test_merge_verify*.py` / `test_orch073_*` / `test_merge_actor.py`.
|
||||
- **FAIL:** любой тест падает.
|
||||
90
docs/work-items/ORCH-082/04-test-plan.yaml
Normal file
90
docs/work-items/ORCH-082/04-test-plan.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
work_item: ORCH-082
|
||||
title: "Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD)"
|
||||
strategy: >
|
||||
Юнит-тесты на новый идемпотентный актор merge_gate.ensure_open_pr (мок Gitea HTTP)
|
||||
и интеграционные тесты на врезку в stage_engine._handle_merge_verify (мок merge_gate
|
||||
+ verify), включая регресс ORCH-073. Все пути — never-raise. Gitea и git мокаются,
|
||||
сеть не дёргается.
|
||||
|
||||
tests:
|
||||
# ---- ensure_open_pr: идемпотентный PR-актор (FR-1) ----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "ensure_open_pr: открытого код-PR нет -> POST создаёт PR -> ('created', N); фильтр base==main применён"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "ensure_open_pr: открытый PR head==branch И base==main уже есть -> ('existed', N), POST не вызывается (нет дубля)"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Грабли мультиPR: у ветки только docs-PR (base!=main) -> он НЕ считается код-PR -> создаётся PR на main (AC-6)"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "ensure_open_pr never-raise: Gitea POST/GET кидает HTTP/timeout -> ('failed', reason), исключение не всплывает (AC-7)"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Идемпотентность гонки: POST вернул 'PR exists' -> повторный GET подтверждает существующий -> ('existed', N), дубль не создан"
|
||||
module: tests/test_orch082_ensure_pr.py
|
||||
expected: PASS
|
||||
|
||||
# ---- _handle_merge_verify: врезка ensure-PR (FR-2/FR-3) ----
|
||||
- id: TC-06
|
||||
type: integration
|
||||
description: "merge-verify: PR отсутствовал -> ensure_open_pr создаёт -> merge_pr -> verify True -> deploy->done БЕЗ ложного HOLD (AC-3)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: "Регресс ORCH-073: PR создан/влит, но verify_merged_to_main=False (код не в main) -> HOLD + set_issue_blocked, НЕ done, без отката (AC-4)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: integration
|
||||
description: "ensure_open_pr -> 'failed' (Gitea down) -> честный HOLD+alert, текст отличается от 'not merged', advance_stage не падает (AC-7)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: "Kill-switch merge_verify_autocreate_pr_enabled=False -> ensure_open_pr не вызывается, 'no open PR' -> прежний HOLD 1:1 (AC-8)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: integration
|
||||
description: "Условность: non-self репо (merge_verify_applies=False) -> врезка no-op, авто-создание не выполняется (AC-9)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "Идемпотентный повторный проход (reaper/reconciler): PR уже existed, merge_pr=already-merged -> verify True -> done, без дублей PR (AC-2/FR-5)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Наблюдаемость (G3 / AC-5) ----
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Логи различают created/existed/failed; HOLD-сообщение create-failed != HOLD-сообщение not-merged (caplog, AC-5)"
|
||||
module: tests/test_orch082_merge_verify_autocreate.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Регресс существующего merge-verify контракта ----
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Happy-path ORCH-071/073 не изменён: merge_pr ok + verify True + регресс-гард ok -> done, merged_to_main: true во frontmatter"
|
||||
module: tests/test_merge_verify.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,221 @@
|
||||
# ADR-001: Гарантированный идемпотентный код-PR перед merge-verify (ensure_open_pr)
|
||||
|
||||
- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81»)
|
||||
- Repo: `orchestrator` (self-hosting)
|
||||
- Связь: амендмент к merge-verify ([adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md),
|
||||
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md));
|
||||
глобально зафиксировано в [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md)
|
||||
- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
### Что случилось (инцидент ORCH-074, 08.06)
|
||||
Деплой ORCH-074 встал на под-гейте merge-verify (ребро `deploy → done`):
|
||||
`run_deploy_finalizer → _handle_merge_verify` вызвал `merge_gate.merge_pr(repo, branch)` и
|
||||
получил `ok=False, "no open PR"` — в Gitea для ветки `feature/ORCH-074-…` **не было открытого
|
||||
PR** с `head.ref==branch` И `base.ref=="main"`. Защита ORCH-073 (fail-closed по «SHA-в-main»)
|
||||
**отработала правильно**: задача удержана на `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert,
|
||||
ложно-зелёного `done` не произошло. Разблокировано вручную — PR #79 создан через Gitea API,
|
||||
finalizer перезапущен, код честно влит, задача `done`. Это **workaround, не фикс**.
|
||||
|
||||
### Root cause (G1, подтверждён код-аудитом)
|
||||
PR создаётся в конвейере **ровно в одном месте** — `AgentLauncher._ensure_pr`
|
||||
(`src/agents/launcher.py:1079`), и вызывается он из `_monitor_agent` **только** по цепочке
|
||||
условий (`src/agents/launcher.py:751–753`):
|
||||
|
||||
```
|
||||
exit_code == 0
|
||||
→ git status --porcelain непусто (есть worktree-изменения)
|
||||
→ git commit succeeded
|
||||
→ git push succeeded
|
||||
→ agent == "developer" ←── ТОЛЬКО здесь вызывается self._ensure_pr(...)
|
||||
```
|
||||
|
||||
Отсюда класс «ветка без PR» структурно неизбежен. Подтверждённые код-аудитом ветви:
|
||||
|
||||
- **R-A (условное создание) — структурный первопричинный дефект.** Если в конкретном
|
||||
developer-run нет свежих изменений (`git status` пуст: ветка уже была закоммичена/запушена
|
||||
ранее, бойнс REQUEST_CHANGES без новых правок, повторный прогон, **ручное восстановление
|
||||
ветки**) — `_ensure_pr` **не вызывается вовсе**. PR не появится никогда. Никакого
|
||||
персистентного флага «PR создан» в БД нет, поэтому идемпотентность чтения внутри `_ensure_pr`
|
||||
до merge-стадии не доходит.
|
||||
- **R-C (разъехавшееся состояние ветки/PR) — проксимальный триггер ORCH-074.** ORCH-074 — первая
|
||||
задача после серии **ручных восстановлений `main` 08.06**: открытый код-PR был закрыт/не
|
||||
пересоздан, у ветки мог остаться лишь авто-docs-PR (`base != main`), который `merge_pr` (фильтр
|
||||
`base=="main"`, ORCH-073 FR-3) корректно НЕ считает кодовым.
|
||||
- **R-B (тихий сбой создания) — потенциальная, не первопричина здесь.** `_ensure_pr` глотает любое
|
||||
исключение (`except Exception → logger.error → return None`): транзиентная ошибка Gitea на
|
||||
`POST …/pulls` теряется без ретрая и эскалации.
|
||||
|
||||
**Вывод:** в конвейере **отсутствует инвариант** «к моменту merge-verify у ветки есть открытый
|
||||
код-PR». Защита ORCH-073 верно ловит следствие, но причина — выше по потоку. Любая следующая
|
||||
задача с тем же стечением обстоятельств застрянет тем же образом → автономный деплой (ORCH-54)
|
||||
заблокирован.
|
||||
|
||||
### Ограничения, которые нельзя нарушать
|
||||
- Защита ORCH-073 (SHA-в-main + регресс-гард) — приоритетна. Создание PR **не должно** маскировать
|
||||
реально невлитый код.
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`,
|
||||
exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений.
|
||||
- Весь путь merge-verify — **never-raise**.
|
||||
- Слияние только через PR; `main` никогда не push/force-push.
|
||||
|
||||
## Решение
|
||||
|
||||
Закрыть пробел инвариантом «обеспечить открытый код-PR» **внутри того же под-гейта merge-verify**,
|
||||
ПЕРЕД детерминированным `merge_pr`. Три точечные врезки, симметричные существующему дизайну
|
||||
ORCH-071/073 (leaf-актор в `merge_gate` + врезка в `_handle_merge_verify` + kill-switch). Машина
|
||||
стадий и реестры не трогаются.
|
||||
|
||||
### Р-1. Новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch)`
|
||||
|
||||
Сигнатура (решение архитектора по ТЗ §1):
|
||||
|
||||
```python
|
||||
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
|
||||
"""Гарантировать открытый код-PR (head==branch, base==main). never-raise.
|
||||
Возврат: ("existed", "<number>") | ("created", "<number>") | ("failed", "<reason>").
|
||||
"""
|
||||
```
|
||||
|
||||
Алгоритм (FR-1):
|
||||
1. `GET …/pulls?state=open` → найти PR с **`head.ref==branch` И `base.ref=="main"`**. Фильтр
|
||||
**идентичен** `merge_pr`/ORCH-073 FR-3 — авто-docs-PR (`base != main`) НЕ считается код-PR
|
||||
(AC-6). Нашли → `("existed", <number>)`.
|
||||
2. Иначе `POST …/pulls` (`head=branch`, `base="main"`, авто-заголовок/тело) → `201` →
|
||||
`("created", <number>)`.
|
||||
3. **Идемпотентность при гонке:** если на `POST` Gitea вернёт «PR exists»/`409`/`422` —
|
||||
повторный `GET` (шаг 1) подтверждает существующий PR → `("existed", …)`. Дубль не плодится
|
||||
(AC-2, FR-5).
|
||||
4. Любая иная HTTP/parse/сетевая ошибка → `("failed", <reason>)`. **Never-raise** (`except
|
||||
Exception → ("failed", str(e))`).
|
||||
|
||||
Актор — **leaf** (зависит только от `settings` + `httpx`, без импорта `stage_engine`), как
|
||||
`merge_pr`/`verify_merged_to_main`. Таймауты — переиспользовать `settings.merge_pr_timeout_s`
|
||||
(тот же класс Gitea-вызовов).
|
||||
|
||||
> **Почему фильтр `base=="main"` критичен** (грабли ORCH-073): у ветки одновременно бывают код-PR
|
||||
> и авто-docs-PR. Без фильтра актор «увидит» docs-PR как existed и не создаст нужный код-PR, а
|
||||
> `merge_pr` потом не найдёт что мержить → петля. Один и тот же предикат `head==branch &&
|
||||
> base=="main"` гарантирует, что `ensure_open_pr` и `merge_pr` работают с одним и тем же PR.
|
||||
|
||||
### Р-2. Врезка в `_handle_merge_verify` (ребро `deploy → done`)
|
||||
|
||||
В существующем `_handle_merge_verify` (`src/stage_engine.py:1324`), **ПОСЛЕ**
|
||||
`merge_verify_applies(repo)`-гейта и резолва `sha = image_freshness.validated_revision(...)`,
|
||||
но **ПЕРЕД** `merge_pr`:
|
||||
|
||||
```python
|
||||
sha = image_freshness.validated_revision(repo, branch)
|
||||
|
||||
# ORCH-082: гарантировать открытый код-PR ДО детерминированного merge_pr.
|
||||
if settings.merge_verify_autocreate_pr_enabled:
|
||||
pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(
|
||||
f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})"
|
||||
)
|
||||
if pr_status == "failed":
|
||||
return _hold_pr_create_failed(
|
||||
task_id, repo, work_item_id, branch, pr_detail, result
|
||||
)
|
||||
# "created" | "existed" -> штатно продолжаем к merge_pr.
|
||||
|
||||
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
|
||||
...
|
||||
```
|
||||
|
||||
Семантика (FR-2):
|
||||
- `created | existed` → продолжаем штатно к `merge_pr` → `verify_merged_to_main` → регресс-гард.
|
||||
- `failed` → **честный HOLD + alert** через новый helper `_hold_pr_create_failed` (см. Р-3); задача
|
||||
остаётся на `deploy` (НЕ `done`), БЕЗ отката на development — симметрично текущему not-merged/
|
||||
regressed HOLD.
|
||||
- kill-switch off → блок пропускается целиком → поведение 1:1 как до фикса (AC-8).
|
||||
|
||||
Место выбрано так, что **никакой существующий шаг не сдвигается**: `merge_pr` и
|
||||
`verify_merged_to_main` остаются на своих местах с теми же контрактами. Создание PR — это только
|
||||
страховка инварианта ДО них.
|
||||
|
||||
### Р-3. Новый HOLD-helper `_hold_pr_create_failed` (распознаваемость причины, FR-4/AC-5)
|
||||
|
||||
Зеркало существующего `_hold_main_regressed` (`src/stage_engine.py:1280`). Текст HOLD **обязан
|
||||
отличаться** от not-merged HOLD: оператор должен видеть, что причина — **невозможность создать
|
||||
PR** (Gitea недоступна), а не **невозможность слить уже созданный**:
|
||||
|
||||
```python
|
||||
def _hold_pr_create_failed(task_id, repo, work_item_id, branch, reason, result) -> bool:
|
||||
merge_gate.note_not_merged_alert(work_item_id) # переиспользуем счётчик-нотификатор
|
||||
msg = (f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, "
|
||||
f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача "
|
||||
f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR.")
|
||||
# set_issue_blocked + plane_add_comment + send_telegram (каждый в try/except, never-break HOLD)
|
||||
result.alerted = True
|
||||
result.note = "pr-create-failed-hold" # отличается от "merge-not-verified-hold"
|
||||
result.advanced = False
|
||||
return True
|
||||
```
|
||||
|
||||
Это сохраняет инвариант «никогда не пробрасываем исключение в `advance_stage`»: `failed` —
|
||||
структурированный исход, а не throw.
|
||||
|
||||
### Р-4. Единый источник кода создания PR (опционально, рекомендуется)
|
||||
|
||||
`launcher._ensure_pr` рекомендуется **делегировать** в `merge_gate.ensure_open_pr`, чтобы создание
|
||||
PR жило в одном месте и одинаково логировало created/existed/failed (G3). **Поведенческий
|
||||
инвариант:** триггер «создавать PR только в developer-пути со свежим коммитом» **НЕ ужесточается**
|
||||
(BRD/ТЗ §1) — меняется лишь реализация под капотом, не условие вызова. Это снижает риск
|
||||
рассинхрона двух копий логики «выбрать/создать PR». Если делегирование увеличивает диффу/риск —
|
||||
допустимо оставить `_ensure_pr` как есть и лишь усилить его логирование (created/existed/failed);
|
||||
функциональная цель ORCH-082 достигается врезкой Р-2 независимо.
|
||||
|
||||
### Р-5. Kill-switch и область действия
|
||||
|
||||
- `merge_verify_autocreate_pr_enabled: bool = True`
|
||||
(env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`) в `src/config.py`, рядом с
|
||||
`merge_verify_enabled`/`regression_guard_enabled`.
|
||||
- `False` → ровно прежнее поведение: авто-создания нет, «no open PR» → HOLD как в ORCH-074 (AC-8).
|
||||
- Область — `merge_gate.merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); прочие
|
||||
репо — no-op, создание PR остаётся за прежним механизмом (AC-9). Отдельного `*_repos` для
|
||||
авто-создания НЕ вводим: семантически оно неотделимо от merge-verify, у которого уже есть область.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- Закрыт структурный пробел: к merge-verify ветка гарантированно имеет открытый код-PR; ложный
|
||||
HOLD «no open PR» больше не требует ручного вмешательства (AC-2/AC-3).
|
||||
- Защита ORCH-073 цела и приоритетна: верификация остаётся **только** `verify_merged_to_main`
|
||||
(SHA-в-main) + `check_main_regression`. Реально невлитый код → HOLD как прежде (AC-4/FR-3).
|
||||
- Идемпотентность по факту Gitea (наличие открытого PR), без новой колонки/таблицы — согласуется с
|
||||
restart-safe-моделью merge-verify; повторный заход (reaper/reconciler/re-approve) → `existed`,
|
||||
дублей нет (FR-5/AC-2).
|
||||
- Распознаваемые исходы в логах и в HOLD-тексте: created / existed / failed (G3/AC-5).
|
||||
- Инварианты сохранены: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
|
||||
exit-коды хука, merge-gate, image-freshness — не тронуты (AC-10). `main` не push/force-push.
|
||||
|
||||
### Минусы / ограничения
|
||||
- Auto-создание PR на ребре `deploy → done` означает, что код-PR может появиться **после** того,
|
||||
как все гейты (security/merge-gate/staging/image-freshness) уже пройдены по ветке. Это безопасно
|
||||
по времени (BRD §6 допущение): ревью/гейты валидируют **код ветки**, а PR — лишь механизм
|
||||
слияния; merge-verify исполняется ПОСЛЕ всех гейтов. PR здесь не обходит ревью.
|
||||
- При недоступности Gitea задача попадёт в HOLD (как и сегодня) — но теперь с явным текстом
|
||||
«PR создать не удалось» вместо «PR не влит». Это сознательный fail-closed (AC-7): never-raise,
|
||||
честный HOLD, не ложно-зелёный `done`.
|
||||
- Небольшое дублирование Gitea-вызовов между `ensure_open_pr` и `merge_pr` (оба GET список PR). Это
|
||||
приемлемо: два независимых leaf-актора с одинаковым фильтром важнее микро-оптимизации; объединять
|
||||
в один вызов — увеличить связность без пользы.
|
||||
|
||||
### Влияние на self-hosting
|
||||
Изменение строго аддитивно и под kill-switch (`True`). Прод-контейнер не рестартится этой задачей;
|
||||
выкат — через staging-гейт (8501) как любая ORCH-задача. На ребре `deploy → done` риск-профиль не
|
||||
растёт: при любом сбое — HOLD, не падение `advance_stage`, конвейер всех проектов не встаёт.
|
||||
|
||||
## Связанные документы
|
||||
- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
|
||||
- Тех-риски: `10-tech-risks.md`
|
||||
- Глобальный амендмент: [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md)
|
||||
- Контекст merge-verify: [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md),
|
||||
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md)
|
||||
- Постмортем фантомного merge: `docs/history/LESSONS_2026-06-08_phantom-merge.md`,
|
||||
runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md`
|
||||
27
docs/work-items/ORCH-082/10-tech-risks.md
Normal file
27
docs/work-items/ORCH-082/10-tech-risks.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 10 — Технические риски: ORCH-082 (ORCH-81)
|
||||
|
||||
Риски точечной врезки «ensure_open_pr перед merge-verify». Все — в зоне ребра `deploy → done`
|
||||
(self-hosting), под kill-switch `merge_verify_autocreate_pr_enabled`.
|
||||
|
||||
| ID | Риск | Вероятн. | Влияние | Митигация |
|
||||
|----|------|----------|---------|-----------|
|
||||
| **R1** | `ensure_open_pr` выбирает/создаёт **не тот** PR (авто-docs-PR `base != main`) → `merge_pr` мержит/верифицирует не тот PR | Сред. | Высокое | Фильтр `head.ref==branch` И `base.ref=="main"`, **идентичный** `merge_pr` (ORCH-073 FR-3). Тест AC-6: ветка с docs-PR (`base!=main`) → актор его игнорирует и создаёт код-PR на `main`. |
|
||||
| **R2** | Создание PR **маскирует** реально невлитый код → ложно-зелёный `done` (регресс ORCH-073) | Низк. | Критич. | Верификация остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` НЕ влияет на вердикт merge. Регресс-тест AC-4: `verify_merged_to_main→False` ⇒ HOLD, не `done`. |
|
||||
| **R3** | Гонка: параллельно создаётся 2 PR → дубль | Низк. | Сред. | Идемпотентность FR-1.3: на ошибку «PR exists»/409/422 — повторный GET → `existed`; PR создаётся только если GET пуст. Тест AC-2. |
|
||||
| **R4** | Исключение из `ensure_open_pr` пробрасывается в `advance_stage` → падение перехода | Низк. | Высокое | Контракт never-raise (`except Exception → ("failed", reason)`); врезка обёрнута внешним try/except `_handle_merge_verify`. `failed` → структурированный HOLD, не throw. Тест AC-7. |
|
||||
| **R5** | Gitea недоступна на ребре `deploy → done` → задача в HOLD | Низк. | Сред. | Сознательный fail-closed: `failed` → честный HOLD+alert (`_hold_pr_create_failed`), НЕ ложный `done`. Текст HOLD отличим от not-merged (AC-5) — оператор видит причину. Reaper/reconciler/re-approve переиграют, когда Gitea вернётся (FR-5). |
|
||||
| **R6** | Оператор не различит HOLD «PR не создан» и HOLD «PR не влит» | Сред. | Низк. | Отдельный helper `_hold_pr_create_failed` с собственным текстом и `result.note="pr-create-failed-hold"` (≠ `merge-not-verified-hold`); лог-строка `ensure_open_pr -> failed: <reason>`. AC-5. |
|
||||
| **R7** | Расхождение логики выбора/создания PR между `launcher._ensure_pr` и `merge_gate.ensure_open_pr` | Сред. | Сред. | Рекомендованное делегирование `_ensure_pr → ensure_open_pr` (единый код). Если не делегируем — обе копии используют ОДИН фильтр `head==branch && base==main`; тест на согласованность. |
|
||||
| **R8** | Включение по умолчанию (`True`) меняет прод-поведение скрытно | Низк. | Сред. | Поведение строго аддитивно: при наличии PR → `existed`/no-op; меняется лишь ранее-падавший путь «no open PR». Kill-switch `False` → 1:1 ORCH-074 (AC-8). Выкат через staging-гейт (8501). |
|
||||
| **R9** | Регресс инвариантов (`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/exit-коды) | Низк. | Высокое | Под-гейт-врезка в `advance_stage`, НЕ новый `QG_CHECKS`-элемент и НЕ новая стадия; БД не трогается (идемпотентность из Gitea). Тест AC-10 + полный `pytest`. |
|
||||
|
||||
## Зоны без изменений (подтверждение границ)
|
||||
- **Инфраструктура/топология** — без изменений → `07-infra-requirements.md` не требуется.
|
||||
- **Схема БД** — без изменений (идемпотентность выводится из Gitea) → `08-data-requirements.md`
|
||||
не требуется.
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука,
|
||||
merge-gate (ORCH-043), image-freshness (ORCH-058), terminal-sync — не тронуты.
|
||||
|
||||
## Главный архитектурный приоритет
|
||||
При любом конфликте «создать PR» **проигрывает** «не дать ложно-зелёный `done`» (защита ORCH-073).
|
||||
Создание PR — страховка инварианта ДО merge_pr, никогда не подмена верификации merge.
|
||||
65
docs/work-items/ORCH-082/12-review.md
Normal file
65
docs/work-items/ORCH-082/12-review.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-082
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-082 — Гарантированный идемпотентный код-PR перед merge-verify
|
||||
|
||||
## Summary
|
||||
Изменение закрывает отсутствующий инвариант «к моменту merge-verify у ветки есть открытый
|
||||
код-PR» (root cause ложного HOLD «no open PR» на деплое ORCH-074). Реализовано строго аддитивно,
|
||||
по дизайну ADR-001: новый идемпотентный leaf-актор `merge_gate.ensure_open_pr`, точечная врезка в
|
||||
`stage_engine._handle_merge_verify` ПЕРЕД `merge_pr`, distinguishable HOLD-helper
|
||||
`_hold_pr_create_failed`, делегирование `launcher._ensure_pr` в единый актор, kill-switch
|
||||
`merge_verify_autocreate_pr_enabled`. Защита ORCH-073 (SHA-в-main + регресс-гард) не ослаблена и
|
||||
остаётся приоритетной. Машина стадий, `QG_CHECKS`, схема БД, контракты деплоя — не тронуты.
|
||||
|
||||
Все 4 оси проверки пройдены:
|
||||
- **ТЗ (02-trz.md):** FR-1..FR-5 реализованы — идемпотентный актор с фильтром
|
||||
`head==branch & base=="main"`, врезка после `validated_revision` и до `merge_pr`, честный HOLD
|
||||
на `failed`, защита ORCH-073 цела, идемпотентность повторного прохода.
|
||||
- **AC (03-acceptance-criteria.md):** AC-1..AC-11 покрыты. Root cause задокументирован в ADR
|
||||
(R-A структурный + R-C проксимальный для ORCH-074); идемпотентность/existed (TC-02, TC-05);
|
||||
autocreate до merge_pr (TC-06); защита ORCH-073 (TC-07); логи различают исход (TC-12); фильтр
|
||||
base==main (TC-03); never-raise (TC-04, TC-08); kill-switch off (TC-09); условность non-self
|
||||
(TC-10); инварианты + документация; pytest зелёный.
|
||||
- **ADR:** реализация 1:1 соответствует Р-1..Р-5 ADR-001; не нарушает глобальные adr-0013/0014
|
||||
(амендмент adr-0016 корректно зарегистрирован).
|
||||
- **Качество кода:** never-raise соблюдён (все внешние вызовы в try/except), docstrings на
|
||||
публичных функциях, тесты содержательные (мок Gitea HTTP + интеграционные на под-гейт, не
|
||||
тривиальные). Секреты не хардкодятся (token из settings). `main` не push/force-push.
|
||||
|
||||
`pytest tests/ -q` → **1046 passed**. Целевые наборы (`test_orch082_ensure_pr.py`,
|
||||
`test_orch082_merge_verify_autocreate.py`, `test_merge_verify.py`) — зелёные.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- [ ] Поведенческое уточнение `launcher._ensure_pr`: после делегирования в `ensure_open_pr`
|
||||
developer-путь теперь требует `base=="main"` (раньше принимался любой открытый PR с
|
||||
`head==branch`). Это корректное усиление (выравнивание с `merge_pr`) и для штатного потока
|
||||
PR всегда создаётся на `main` — регресса нет; зафиксировано для истории, действий не требует.
|
||||
|
||||
## Документация
|
||||
Документация обновлена в том же PR — соответствие правилу №2/№6 CLAUDE.md:
|
||||
- `docs/architecture/README.md` — добавлен раздел ORCH-082 в блок merge-verify (строки 209-240).
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`.
|
||||
- `.env.example` — `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true` + комментарий.
|
||||
- `docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md` — сквозной ADR (амендмент
|
||||
adr-0013/0014), зарегистрирован в `docs/architecture/adr/README.md` (макс. номер → 0016).
|
||||
- `docs/work-items/ORCH-082/06-adr/ADR-001-*.md` — детальный ADR (root cause + дизайн).
|
||||
- API сервиса не менялось (новых endpoint нет), конфиг-флаг отражён в `.env.example`. Все
|
||||
изменения `src/` (merge_gate, stage_engine, launcher, config) задокументированы.
|
||||
|
||||
**Вердикт: APPROVED** — P0/P1 отсутствуют, документация обновлена, тесты зелёные.
|
||||
81
docs/work-items/ORCH-082/13-test-report.md
Normal file
81
docs/work-items/ORCH-082/13-test-report.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-082
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-082
|
||||
|
||||
Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-082-orch-81-pr-merge-verify-hold
|
||||
- Дата: 2026-06-09
|
||||
- Review verdict: APPROVED (12-review.md, P0/P1 отсутствуют)
|
||||
|
||||
## Проверка окружения
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — прод-контейнер 8500 жив.
|
||||
- Тесты прогнаны в worktree ветки (прод не затронут, деструктивных операций нет).
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok"}` — OK |
|
||||
| `GET /status` | OK — ORCH-082 (id=61) виден на стадии `testing` |
|
||||
| `GET /queue` | OK — `running:1, queued:0`, breaker `closed`, reconcile/reaper/post_deploy активны |
|
||||
|
||||
## Результаты (привязка к 04-test-plan.yaml)
|
||||
|
||||
| TC ID | Тип | Описание | Тест | Результат |
|
||||
|-------|-----|----------|------|-----------|
|
||||
| TC-01 | unit | ensure_open_pr: PR нет → POST создаёт → ('created', N); фильтр base==main | test_tc01_creates_pr_when_absent | PASS |
|
||||
| TC-02 | unit | PR head==branch И base==main уже есть → ('existed', N), POST не вызывается | test_tc02_existed_no_duplicate | PASS |
|
||||
| TC-03 | unit | Мульти-PR: только docs-PR (base!=main) → создаётся PR на main (AC-6) | test_tc03_docs_pr_not_counted_creates_on_main | PASS |
|
||||
| TC-04 | unit | never-raise: GET/POST кидает ошибку → ('failed', reason), не всплывает (AC-7) | test_tc04_never_raise_on_get_error / _on_post_error / _failed_when_post_non_2xx | PASS (3) |
|
||||
| TC-05 | unit | Гонка: POST 'PR exists' (409/422) → повторный GET → ('existed', N), без дубля | test_tc05_race_post_conflict_confirms_existing[409,422] | PASS (2) |
|
||||
| TC-06 | integration | PR отсутствовал → ensure создаёт → merge_pr → verify True → done без HOLD (AC-3) | test_tc06_autocreate_then_merge_then_done | PASS |
|
||||
| TC-07 | integration | Регресс ORCH-073: verify=False → HOLD + set_issue_blocked, НЕ done, без отката (AC-4) | test_tc07_verify_false_still_holds | PASS |
|
||||
| TC-08 | integration | ensure → 'failed' (Gitea down) → честный HOLD+alert, текст ≠ 'not merged' (AC-7) | test_tc08_ensure_failed_holds_distinct | PASS |
|
||||
| TC-09 | integration | Kill-switch off → ensure не вызывается, 'no open PR' → прежний HOLD 1:1 (AC-8) | test_tc09_killswitch_off_no_autocreate | PASS |
|
||||
| TC-10 | integration | Условность: non-self репо (applies=False) → no-op, авто-создание не выполняется (AC-9) | test_tc10_non_self_repo_noop | PASS |
|
||||
| TC-11 | integration | Идемпотентный повторный проход: PR existed, already-merged → verify True → done (FR-5) | test_tc11_idempotent_redrive | PASS |
|
||||
| TC-12 | unit | Логи различают created/existed/failed; HOLD create-failed ≠ HOLD not-merged (AC-5) | test_tc12_logs_distinguish_outcomes | PASS |
|
||||
| TC-13 | integration | Happy-path ORCH-071/073 не изменён: verify True → done, merged_to_main: true | test_merge_verify.py (verify_true_when_sha_is_ancestor + 7 регресс-тестов) | PASS |
|
||||
|
||||
Все 13 TC из тест-плана покрыты и зелёные.
|
||||
|
||||
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
|
||||
- **AC-1** Root cause в ADR-001 (R-A структурный + R-C для ORCH-074) — подтверждено review.
|
||||
- **AC-2** Идемпотентный код-PR, без дублей — TC-02, TC-05, TC-11 — PASS.
|
||||
- **AC-3** Авто-создание PR ПЕРЕД merge_pr — TC-06 — PASS.
|
||||
- **AC-4** Защита ORCH-073 цела (verify=False → HOLD, не done) — TC-07 + test_merge_verify — PASS.
|
||||
- **AC-5** Логи различают исход PR — TC-12 — PASS.
|
||||
- **AC-6** Фильтр base==main (docs-PR не код-PR) — TC-03 — PASS.
|
||||
- **AC-7** Never-raise + честный HOLD при недоступности Gitea — TC-04, TC-08 — PASS.
|
||||
- **AC-8** Kill-switch off → поведение 1:1 — TC-09 — PASS.
|
||||
- **AC-9** Условность self-hosting — TC-10 — PASS.
|
||||
- **AC-10** Инварианты не нарушены, документация обновлена — подтверждено review (README/CHANGELOG/.env.example/ADR).
|
||||
- **AC-11** pytest зелёный — **1046 passed** — PASS.
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полный прогон:
|
||||
```
|
||||
1046 passed, 1 warning in 25.57s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в src/config.py:5, не относится к ORCH-082, предсуществующий.)
|
||||
|
||||
Целевые наборы:
|
||||
```
|
||||
tests/test_orch082_ensure_pr.py ............ (8 passed)
|
||||
tests/test_orch082_merge_verify_autocreate.py ....... (7 passed)
|
||||
tests/test_merge_verify.py ........ (8 passed)
|
||||
======================== 23 passed, 1 warning in 0.42s =========================
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS** — все 1046 тестов зелёные, целевые наборы ORCH-082 + регресс merge-verify зелёные,
|
||||
smoke API (health/status/queue) OK, все 13 TC и AC-1..AC-11 покрыты. Задача готова к переходу
|
||||
на стадию `deploy-staging`.
|
||||
12
docs/work-items/ORCH-082/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-082/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-082
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
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.
|
||||
7
docs/work-items/ORCH-086/00-business-request.md
Normal file
7
docs/work-items/ORCH-086/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-86: reconciler шлёт в Telegram «ET-002 done разблокирована (потерян webhook)» периодически
|
||||
|
||||
Work Item ID: ORCH-086
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
70
docs/work-items/ORCH-086/01-brd.md
Normal file
70
docs/work-items/ORCH-086/01-brd.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 01-BRD — ORCH-086: reconciler шлёт ложное «ET-002 done разблокирована»
|
||||
|
||||
Work Item: **ORCH-086**
|
||||
Тип: **Багфикс** (шум уведомлений / остаток livelock)
|
||||
Приоритет: **MEDIUM**
|
||||
Зона: `src/reconciler.py`
|
||||
Связано: продолжение **ORCH-068** (тот фикс задеплоен, но НЕ закрыл этот путь), наследует контракты **ORCH-053 / ORCH-060 / ORCH-066**.
|
||||
|
||||
## 1. Контекст / проблема
|
||||
В Telegram периодически (а особенно сразу после рестарта оркестратора) прилетает уведомление:
|
||||
|
||||
> 🔧 reconciler: ET-002 done разблокирована (потерян webhook)
|
||||
|
||||
Это **ложный шум**: задача `ET-002` (проект enduro-trails) давно завершена, реально ничего не разблокируется. Уведомление вводит наблюдателя в заблуждение (создаёт впечатление, что конвейер чинит застрявшую задачу, хотя ничего не происходит).
|
||||
|
||||
ORCH-068 уже починил аналогичный livelock на **F-2 (plane-side)**: добавил per-issue терминал-исключение (`_is_terminal_state`, группа Plane `completed`/`cancelled`) и in-memory dedup-guard по `issue_id→state_uuid`. Однако эти две защиты **не покрывают путь F-1 (gate-side)**.
|
||||
|
||||
## 2. Диагностика (код-аудит, golden source — текущий `src/reconciler.py`)
|
||||
|
||||
Уведомление отправляет `Reconciler._note_unblock()` (`reconciler.py` ~стр.444) через `send_telegram()` при `settings.reconcile_notify_unblock=True`.
|
||||
|
||||
Два механизма ORCH-068, которые ДОЛЖНЫ были его подавить, на пути F-1 не работают:
|
||||
|
||||
1. **Dedup-guard не срабатывает.** Guard ключуется по `state_uuid` и активен только когда `state_uuid is not None` (`_note_unblock`, стр.459–463). Но вызов в F-1 (`_reconcile_gate_task`, стр.228):
|
||||
```python
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
```
|
||||
передаёт **только 2 аргумента, БЕЗ `state_uuid`** → ветка dedup пропускается → уведомление шлётся при каждом релевантном тике/старте. (В отличие от F-2, где все 4 вызова `_note_unblock` передают `state_uuid` — стр.394/400/407/416.)
|
||||
|
||||
2. **Терминал-скип не ловит этот путь.** Терминал-исключение ORCH-068 (`_is_terminal_state`, стр.327–344) вызывается **только в F-2** (`_reconcile_plane_issue`, стр.362). В F-1 единственный «терминал-фильтр» — это `get_active_tasks_for_reconcile()` (`db.py` стр.193: `WHERE stage != 'done'`), который смотрит **только на стадию задачи в БД оркестратора** и НЕ знает о статусе задачи в Plane (группа `completed`/`cancelled`). Поэтому задача, которая в БД оркестратора стоит на НЕ-`done` стадии (дрейф), а в Plane уже `Done`, проходит фильтр.
|
||||
|
||||
### Почему `advance_if_gate_passed` считает ET-002 «продвинувшейся» (G1 — гипотеза, требует подтверждения в development)
|
||||
Для enduro-trails (не self-hosting) условные гейты (`check_staging_status`, `check_deploy_status`, merge-gate, image-freshness, security-gate, merge-verify) — **no-op `(True, ...)`** (условность ORCH-35/43/58/71). Поэтому для enduro-задачи, чья стадия в БД оркестратора НЕ `done`, но застряла перед терминалом (например `deploy`), `advance_if_gate_passed` находит гейт зелёным (no-op) → вызывает `advance_stage(..., finished_agent=None)` → возвращает `result.advanced=True` (стр.227) → доходит до `_note_unblock`. Guard 2 (`_is_blocked_or_needs_input`, стр.230) задачу не спасает: его `skip_set` = `{blocked, needs_input, extra_waits}` и **НЕ содержит `done`/`cancelled`** → терминальная-в-Plane задача через него проходит. «Периодичность / при старте» объясняется отсутствием dedup (state_uuid не передан) + чистым in-memory состоянием нового процесса после рестарта (первый проход снова находит задачу).
|
||||
|
||||
> **Открытый вопрос для G1 (подтвердить в development по prod-БД/логам):** точная стадия `ET-002` в БД оркестратора в момент срабатывания (в quoted-сообщении фигурирует слово «done», но `get_active_tasks_for_reconcile` исключает `stage='done'` — значит стадия в БД иная либо аномальная). Фикс обязан быть **робастным независимо** от точной стадии: терминальность определяется по группе статуса Plane (как `_is_terminal_state`), а не по строковому совпадению стадии.
|
||||
|
||||
## 3. Бизнес-цели
|
||||
- **G1.** Установить и задокументировать, почему F-1 (`advance_if_gate_passed`) доводит терминальную в Plane задачу (ET-002) до `_note_unblock` на каждом релевантном тике/старте.
|
||||
- **G2.** Не слать unblock-уведомление для задач, УЖЕ терминальных (`done`/`cancelled`) в Plane (по группе статуса) и/или в оркестраторе — распространить терминал-скип ORCH-068 на путь F-1 (стр.228), а не только на F-2.
|
||||
- **G3.** Передавать `state_uuid` в `_note_unblock` на **всех** путях (включая F-1) → in-memory dedup-guard работает везде (страховка от повтора, даже если терминал-скип когда-то не сработает).
|
||||
|
||||
## 4. Объём (Scope)
|
||||
**В объёме:**
|
||||
- Точечная правка `src/reconciler.py`: терминал-скип на пути F-1 + проброс `state_uuid` в `_note_unblock` из F-1.
|
||||
- Сохранение/корректное инкрементирование наблюдаемости ORCH-068 (`skipped_terminal_total`, `deduped_total`, `unblocked_total`).
|
||||
- Unit-тесты, покрывающие AC-1…AC-5.
|
||||
- Обновление документации (`docs/architecture/README.md` блок Reconciler, `CHANGELOG.md`).
|
||||
|
||||
**Вне объёма (Не-цели):**
|
||||
- НЕ ломать легитимный replay реально застрявшей задачи (когда реконсиляция её ДЕЙСТВИТЕЛЬНО двигает — уведомление полезно).
|
||||
- НЕ трогать пайплайн / статусы enduro-trails.
|
||||
- НЕ отключать `reconcile_notify_unblock` глобально (потеряем полезные алерты) — подавление **точечное**, только для терминальных.
|
||||
- НЕ менять `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схему БД, контракты `advance_stage` / `advance_if_gate_passed`.
|
||||
- НЕ менять поведение F-2 (там ORCH-068 уже корректен) сверх необходимого переиспользования хелперов.
|
||||
|
||||
## 5. Заинтересованные лица
|
||||
- **Owner / Слава** — наблюдатель Telegram-карточек и алертов; страдающая сторона (шум).
|
||||
- **enduro-trails** — проект, чьи терминальные задачи генерируют ложные алерты; пайплайн не должен быть затронут.
|
||||
- **orchestrator (self-hosting)** — терминал-детект должен корректно работать и для self (разные наборы Plane-статусов).
|
||||
|
||||
## 6. Риски и ограничения
|
||||
- **R1 (грабли мультипроектности).** enduro-trails и orchestrator — разные проекты с разными наборами Plane-статусов. Терминал-детект ОБЯЗАН работать для обоих: первичный дискриминатор — группа статуса Plane (`completed`/`cancelled`, project-independent), fallback — логические ключи `done`/`cancelled` (как в существующем `_is_terminal_state`, стр.338–344).
|
||||
- **R2 (наблюдаемость).** Нельзя сломать счётчики ORCH-068. При скипе терминальной задачи в F-1 — инкрементировать `skipped_terminal_total` (единая семантика с F-2). `deduped_total`/`unblocked_total` — без регрессии.
|
||||
- **R3 (never-raise).** Тик реконсилятора обязан оставаться never-raise (сеть Plane может быть недоступна). Сбой терминал-проверки → консервативное поведение (как Guard 2: при ошибке скорее НЕ слать, чем слать ложно; но НЕ ценой подавления легитимного unblock — см. AC-4).
|
||||
- **R4 (доп. сетевой вызов).** F-1 для проброса `state_uuid` и терминал-детекта должен знать текущий Plane-статус issue. Guard 2 (`_is_blocked_or_needs_input`) уже делает `fetch_issue_state`. Желательно переиспользовать один fetch, не удваивая обращения к Plane API на тик (производительность горячего цикла).
|
||||
- **R5 (ложно-отрицательный риск).** Слишком агрессивное подавление может задушить полезный алерт о реально застрявшей задаче → обязателен регресс-тест AC-4.
|
||||
|
||||
## 7. Метрика успеха
|
||||
- В Telegram больше нет периодического «ET-002 done разблокирована»; `skipped_terminal_total` растёт (наблюдаемо в `GET /queue`).
|
||||
- `pytest tests/ -q` зелёный; новые тесты AC-1…AC-5 проходят.
|
||||
68
docs/work-items/ORCH-086/02-trz.md
Normal file
68
docs/work-items/ORCH-086/02-trz.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 02-TRZ — ORCH-086: терминал-скип и dedup на пути F-1 реконсилятора
|
||||
|
||||
> Техническое задание. Архитектурное решение (КАК именно) — за архитектором (ADR). Здесь — ЧТО должно измениться и инварианты.
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
- **`src/reconciler.py`** — основной (и, как ожидается, единственный) изменяемый модуль:
|
||||
- `Reconciler._reconcile_gate_task` (стр.180–228) — путь F-1, где находится баг.
|
||||
- `Reconciler._note_unblock` (стр.444–477) — точка отправки уведомления + dedup-guard.
|
||||
- `Reconciler._is_terminal_state` (стр.327–344) — существующий терминал-детект (сейчас зовётся только из F-2); переиспользуется в F-1.
|
||||
- `Reconciler._is_blocked_or_needs_input` (стр.230–288) — уже делает `fetch_issue_state`; желательно переиспользовать его результат, чтобы не удваивать сетевой вызов.
|
||||
- **Возможно затрагиваемые (read-only переиспользование, без изменения контракта):** `src/plane_sync.py` (`fetch_issue_state`, `get_project_states`, `get_project_state_groups`), `src/projects.py` (`get_project_by_repo`). Изменять их не требуется.
|
||||
- **НЕ затрагиваются:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), `src/stage_engine.py` (`advance_stage`/`advance_if_gate_passed`), `src/db.py` (схема), `src/config.py` (новые флаги не вводятся).
|
||||
|
||||
## 2. Требуемые изменения (функциональные)
|
||||
|
||||
### TR-1 (G2): терминал-скип на пути F-1
|
||||
В `_reconcile_gate_task` ДО вызова `_note_unblock` (а лучше — до/вместо доведения терминальной задачи до `advance_if_gate_passed`) добавить проверку: **является ли задача терминальной**.
|
||||
- Терминальность определяется тем же способом, что и в F-2 (`_is_terminal_state`): первичный дискриминатор — **группа статуса Plane** issue ∈ `{completed, cancelled}`; fallback (группа недоступна) — логические ключи `done`/`cancelled` проекта. Это покрывает грабли R1 (enduro vs orchestrator).
|
||||
- Дополнительно: терминальной считается и задача, чья **стадия в БД оркестратора** ∈ `{done, cancelled}` (на случай дрейфа Plane↔БД; `get_active_tasks_for_reconcile` уже отсекает `done`, но `cancelled` — нет).
|
||||
- Терминальная задача → **return без advance и без `_note_unblock`**; инкремент `self.skipped_terminal_total` (единая семантика с F-2, стр.363).
|
||||
- Скип **безусловный** (как терминал-скип F-2 — без отдельного kill-switch). Это НЕ маскирует легитимный replay: реально застрявшая задача терминальной в Plane не бывает.
|
||||
|
||||
> **Где именно** ставить проверку (до `advance_if_gate_passed` или внутри/перед `_note_unblock`) — решает архитектор. Рекомендация: ставить как ранний guard в `_reconcile_gate_task` рядом с Guard 1/Guard 2 (чтобы терминальная задача даже не запускала `advance_if_gate_passed`/гейт). Если терминал-детект требует Plane-статус, он логично переиспользует fetch из Guard 2.
|
||||
|
||||
### TR-2 (G3): проброс `state_uuid` в `_note_unblock` из F-1
|
||||
Вызов на стр.228 должен передавать `state_uuid` (текущий Plane-state issue), чтобы in-memory dedup-guard (`_unblock_dedup`, стр.459–463) работал и на пути F-1:
|
||||
```python
|
||||
# было:
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
# должно (концептуально):
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage, state_uuid)
|
||||
```
|
||||
- `state_uuid` — текущий uuid статуса issue в Plane (тот же, что используется для терминал-детекта TR-1).
|
||||
- Если Plane недоступен и `state_uuid` достоверно получить нельзя → допустимо передать `None` (dedup деградирует в no-op, как сегодня), НО приоритетно сначала отрабатывает терминал-скип TR-1; never-raise сохраняется.
|
||||
- **Сигнатуру `_note_unblock` не менять** (3-й параметр `state_uuid` уже опциональный, стр.445).
|
||||
|
||||
### TR-3: переиспользование сетевого вызова (R4, нефункц., желательно)
|
||||
F-1 не должен делать > 1 обращения к Plane API на задачу за тик ради статуса. `_is_blocked_or_needs_input` уже вызывает `fetch_issue_state`. Архитектор решает форму переиспользования (например, вынести резолв `(project_states, groups, current_state_uuid)` в один helper, питающий Guard 2 + терминал-скип TR-1 + dedup TR-2). Допустимо и без рефакторинга, если число вызовов на тик не растёт значимо.
|
||||
|
||||
## 3. Контракты и инварианты (НЕ нарушать)
|
||||
- **never-raise:** каждая единица работы F-1 изолирована (`_reconcile_gate_task` уже под `try/except` в `reconcile_gate_once`, стр.162–168). Любая ошибка терминал-детекта/fetch → не падает тик; консервативное поведение (R3): при невозможности достоверно определить терминальность — НЕ слать ложно, но и не глушить легитимный (см. AC-4: легитимный unblock — это реальная смена стадии не-терминальной задачи; терминал-неопределённость к нему не относится).
|
||||
- **silence-when-in-sync:** терминальная (= полностью синхронизированная) задача → тишина (инвариант ORCH-068 AC-1/AC-2, теперь и для F-1).
|
||||
- **Легитимный unblock сохраняется:** не-терминальная реально застрявшая задача с зелёным гейтом по-прежнему `advance` + уведомление (AC-4).
|
||||
- **Наблюдаемость ORCH-068:** `skipped_terminal_total` инкрементируется при терминал-скипе F-1; `deduped_total` — при подавлении повтора dedup'ом; `unblocked_total`/`last_unblocked` — только при реальной отправке. Снимок `status()` (стр.516–528) и блок `reconcile` в `GET /queue` — без структурных изменений.
|
||||
- **Условность мультипроекта:** терминал-детект работает и для enduro, и для orchestrator (по группе статуса + fallback). Пайплайн/статусы enduro не трогаются.
|
||||
|
||||
## 4. Изменения API
|
||||
Нет. HTTP-эндпоинты не меняются. `GET /queue` блок `reconcile` сохраняет форму (значения счётчиков — наблюдаемое поведение).
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
Нет. Миграции нет. (Терминальность Plane резолвится онлайн, как в ORCH-068 / Guard 2 — Вариант A без колонки статуса в `tasks`.)
|
||||
|
||||
## 6. Новые/изменённые QG checks
|
||||
Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются.
|
||||
|
||||
## 7. Конфигурация
|
||||
Новые флаги НЕ вводятся. Терминал-скип безусловен (как у F-2). Существующие `reconcile_enabled`, `reconcile_notify_unblock`, `reconcile_skip_blocked_enabled`, `reconcile_plane_enabled` — без изменений семантики. (`reconcile_skip_blocked_enabled` гейтит ТОЛЬКО Guard 2; терминал-скип TR-1 ему НЕ подчиняется.)
|
||||
|
||||
## 8. Артефакты pipeline, подлежащие обновлению (документация = golden source)
|
||||
- `docs/architecture/README.md` — раздел «Reconciler … ORCH-068»: дописать, что терминал-исключение и dedup теперь покрывают и F-1 (gate-side), не только F-2.
|
||||
- `CHANGELOG.md` — запись `fix:` про ORCH-086.
|
||||
- `docs/work-items/ORCH-086/06-adr/ADR-NNN-*.md` — ADR (создаёт архитектор).
|
||||
- (Опционально) краткая ссылка в ADR ORCH-068, что F-1-пробел закрыт ORCH-086.
|
||||
|
||||
## 9. Готовность к development (Definition of Ready)
|
||||
- G1 подтверждён по prod-логам/БД (точная стадия ET-002 и путь срабатывания задокументированы в ADR/12-review).
|
||||
- Тест-план `04-test-plan.yaml` реализован в `tests/test_reconciler.py`.
|
||||
- `pytest tests/ -q` зелёный.
|
||||
32
docs/work-items/ORCH-086/03-acceptance-criteria.md
Normal file
32
docs/work-items/ORCH-086/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 03-Acceptance Criteria — ORCH-086
|
||||
|
||||
Каждый критерий формулирует чёткое условие PASS/FAIL. Проверяется автотестами (`tests/test_reconciler.py`) и код-ревью.
|
||||
|
||||
## AC-1 — ET-002 (терминальная) больше не генерирует «разблокирована»
|
||||
**Дано:** F-1 (`reconcile_gate_once`) обрабатывает задачу enduro, чья стадия в БД оркестратора НЕ-`done` (дрейф), а текущий статус в Plane — терминальный (`Done`, группа `completed`); гейт стадии зелёный (для enduro — no-op `True`).
|
||||
- **PASS:** `_note_unblock` НЕ вызывается → `send_telegram` НЕ вызывается ни при обычном тике, ни при первом проходе после старта (свежий процесс/чистый `_unblock_dedup`).
|
||||
- **FAIL:** уведомление «… разблокирована (потерян webhook)» отправлено хотя бы раз.
|
||||
|
||||
## AC-2 — терминальные задачи (done/cancelled) не доходят до `_note_unblock`
|
||||
**Дано:** задача терминальна в Plane (группа `completed` или `cancelled`) ИЛИ её стадия в БД ∈ `{done, cancelled}`.
|
||||
- **PASS:** F-1 делает ранний скип (нет `advance` / нет `_note_unblock`); `skipped_terminal_total` увеличен на 1 на каждую такую задачу за тик.
|
||||
- **FAIL:** терминальная задача доходит до `advance_if_gate_passed`→`_note_unblock`, либо `skipped_terminal_total` не растёт.
|
||||
- **Грабли (R1):** условие должно срабатывать для ОБОИХ проектов — enduro (терминал по группе `completed`/`cancelled`, либо fallback-ключ `done`/`cancelled`) и orchestrator (свой набор статусов). Тест покрывает оба пути терминал-детекта: (а) по группе, (б) fallback по логическому ключу при пустых `groups`.
|
||||
|
||||
## AC-3 — `_note_unblock` на всех путях получает `state_uuid` → dedup покрывает все вызовы
|
||||
**Дано:** легитимный unblock реально застрявшей НЕ-терминальной задачи на пути F-1 (гейт зелёный, стадия сменилась).
|
||||
- **PASS:** `_note_unblock` вызван с непустым `state_uuid`; повторный вызов для того же `issue_id`+`state_uuid` (например на следующем тике до фактической смены статуса) подавляется dedup-guard'ом → `deduped_total` растёт, второго `send_telegram` нет.
|
||||
- **FAIL:** F-1 зовёт `_note_unblock` без `state_uuid` (2 аргумента) → dedup не работает → повторные уведомления.
|
||||
|
||||
## AC-4 — легитимный unblock реально застрявшей задачи ПО-ПРЕЖНЕМУ уведомляет (анти-регресс)
|
||||
**Дано:** НЕ-терминальная задача (Plane-статус рабочий, не `done`/`cancelled`/`blocked`/`needs_input`), реально застрявшая (прошла grace, нет active-job), гейт зелёный → F-1 её продвигает (`result.advanced=True`).
|
||||
- **PASS:** `_note_unblock` вызван ОДИН раз; при `reconcile_notify_unblock=True` отправлен ровно один Telegram; `unblocked_total` += 1.
|
||||
- **FAIL:** уведомление подавлено (полезный алерт задушен) ИЛИ отправлено более одного раза за одну смену стадии.
|
||||
|
||||
## AC-5 — pytest зелёный; never-raise в тике сохранён
|
||||
- **PASS:** `pytest tests/ -q` зелёный; при исключении внутри терминал-детекта/`fetch_issue_state`/`_reconcile_gate_task` тик НЕ падает (изоляция per-task), и ложное уведомление при ошибке НЕ отправляется (консервативно).
|
||||
- **FAIL:** падение тика, незелёный pytest, либо исключение терминал-детекта приводит к ложной отправке.
|
||||
|
||||
## AC-6 — без регрессий смежного поведения (контрактный)
|
||||
- **PASS:** F-2 (plane-side) терминал-скип/dedup/счётчики работают как в ORCH-068; `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue` — не изменены; новые config-флаги не введены; `reconcile_skip_blocked_enabled` по-прежнему гейтит только Guard 2 (терминал-скип ему не подчинён). Документация (`README.md`, `CHANGELOG.md`) обновлена в том же PR.
|
||||
- **FAIL:** любое из перечисленного нарушено.
|
||||
110
docs/work-items/ORCH-086/04-test-plan.yaml
Normal file
110
docs/work-items/ORCH-086/04-test-plan.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
work_item: ORCH-086
|
||||
description: >
|
||||
Терминал-скип и проброс state_uuid на пути F-1 реконсилятора.
|
||||
Тесты добавляются в tests/test_reconciler.py (рядом с существующими TC-01..TC-21),
|
||||
переиспользуя фикстуры fresh_db / silence_side_effects / _green_ci /
|
||||
plane_state_not_blocked и спай send_telegram. Все тесты — pytest, оффлайн
|
||||
(Plane/Telegram мокаются), детерминированные.
|
||||
tests:
|
||||
- id: TC-86-01
|
||||
type: unit
|
||||
description: >
|
||||
AC-1 — задача enduro НЕ-done в БД, но терминальная в Plane (group=completed),
|
||||
гейт зелёный: F-1 НЕ вызывает _note_unblock и НЕ шлёт Telegram (ни при тике,
|
||||
ни на первом проходе свежего Reconciler).
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-02
|
||||
type: unit
|
||||
description: >
|
||||
AC-2 — терминал-скип инкрементирует skipped_terminal_total и НЕ вызывает
|
||||
advance_if_gate_passed для терминальной задачи (advance_stage-спай не дёрнут).
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-03
|
||||
type: unit
|
||||
description: >
|
||||
AC-2/R1 — терминал-детект по ГРУППЕ статуса Plane (completed/cancelled)
|
||||
срабатывает независимо от проекта (enduro и orchestrator): задача в группе
|
||||
cancelled тоже скипается.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-04
|
||||
type: unit
|
||||
description: >
|
||||
AC-2/R1 — fallback терминал-детекта при пустых groups: терминальность по
|
||||
логическому ключу done/cancelled проекта. Пустой groups + state_uuid ==
|
||||
states['done'] -> скип.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-05
|
||||
type: unit
|
||||
description: >
|
||||
AC-2 — терминальность по стадии БД оркестратора: задача со stage='cancelled'
|
||||
(не отсекается get_active_tasks_for_reconcile, которое фильтрует только 'done')
|
||||
скипается, не доходит до _note_unblock.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-06
|
||||
type: unit
|
||||
description: >
|
||||
AC-3 — F-1 вызывает _note_unblock С непустым state_uuid (3 аргумента) на
|
||||
легитимном unblock; проверяется, что dedup сохраняет ключ issue_id->state_uuid.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-07
|
||||
type: unit
|
||||
description: >
|
||||
AC-3 — повторный F-1-тик для того же issue+state_uuid подавляется dedup-guard:
|
||||
deduped_total += 1, второго send_telegram нет.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-08
|
||||
type: unit
|
||||
description: >
|
||||
AC-4 (анти-регресс) — НЕ-терминальная реально застрявшая задача (рабочий
|
||||
Plane-статус, прошла grace, нет active-job, гейт зелёный) ПО-ПРЕЖНЕМУ
|
||||
продвигается и шлёт РОВНО один Telegram; unblocked_total += 1.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-09
|
||||
type: unit
|
||||
description: >
|
||||
AC-5 — never-raise: исключение в терминал-детекте / fetch_issue_state не
|
||||
роняет тик (reconcile_gate_once завершается) и НЕ приводит к ложной отправке
|
||||
Telegram (консервативно: при неопределённости терминальности не уведомляем).
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-10
|
||||
type: unit
|
||||
description: >
|
||||
AC-6 — регресс F-2: существующие TC F-2 (терминал-скип/dedup/счётчики
|
||||
ORCH-068) остаются зелёными; форма status()/GET-queue не изменилась
|
||||
(skipped_terminal_total, deduped_total, unblocked_total присутствуют).
|
||||
module: tests/test_reconciler_plane.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-11
|
||||
type: unit
|
||||
description: >
|
||||
AC-6 — reconcile_skip_blocked_enabled=False (escape hatch Guard 2) НЕ
|
||||
отключает терминал-скип TR-1: терминальная задача всё равно скипается.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-86-12
|
||||
type: unit
|
||||
description: >
|
||||
Полный прогон регрессии пакета reconciler: pytest tests/test_reconciler.py
|
||||
tests/test_reconciler_plane.py tests/test_config.py -q зелёный.
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,197 @@
|
||||
# ADR-001: Терминал-скип и `state_uuid`-dedup на пути F-1 реконсилятора (одиночный fetch)
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
Связано: продолжение **ORCH-068** (терминал-исключение + dedup для F-2), наследует контракты
|
||||
**ORCH-053** (`adr-0007-reconciler.md`), **ORCH-060** (Guard 1/Guard 2), **ORCH-066** (статусная
|
||||
модель Plane). Не вводит сквозного решения — точечный фикс существующего компонента
|
||||
`src/reconciler.py`; глобальный `adr-00NN` НЕ заводится (см. §«Область и масштаб»).
|
||||
|
||||
## Контекст
|
||||
|
||||
В Telegram периодически (особенно сразу после рестарта орка) прилетает ложное
|
||||
`🔧 reconciler: ET-002 done разблокирована (потерян webhook)`. Задача `ET-002`
|
||||
(enduro-trails) давно завершена; реально ничего не разблокируется — это шум, вводящий
|
||||
наблюдателя в заблуждение.
|
||||
|
||||
ORCH-068 закрыл аналогичный livelock **только на F-2 (plane-side)** двумя механизмами:
|
||||
1. `_is_terminal_state(state_uuid, states, groups)` — терминал-исключение по **группе статуса
|
||||
Plane** (`completed`/`cancelled`, project-independent) с fallback на логические ключи
|
||||
`done`/`cancelled`. Вызывается **только** из `_reconcile_plane_issue` (F-2, `reconciler.py:362`).
|
||||
2. In-memory dedup-guard `_unblock_dedup` (`issue_id → state_uuid`) внутри `_note_unblock`
|
||||
(`reconciler.py:459`), активный **только когда `state_uuid is not None`**.
|
||||
|
||||
Оба механизма **не покрывают путь F-1 (gate-side)**. Код-аудит (golden source — текущий
|
||||
`src/reconciler.py`) подтверждает две независимые причины:
|
||||
|
||||
- **Причина A — dedup не срабатывает.** Вызов F-1 (`_reconcile_gate_task`, `reconciler.py:228`)
|
||||
передаёт `_note_unblock(work_item_id, stage)` — **только 2 аргумента, без `state_uuid`**. Ветка
|
||||
dedup (`reconciler.py:459–463`) пропускается → уведомление шлётся на каждом релевантном тике, а
|
||||
после рестарта `_unblock_dedup` пуст → первый проход снова шлёт.
|
||||
|
||||
- **Причина B — нет терминал-скипа.** Единственный «терминал-фильтр» F-1 —
|
||||
`get_active_tasks_for_reconcile()` (`db.py`, `WHERE stage != 'done'`), который смотрит **только
|
||||
на стадию задачи в БД орка** и не знает о статусе issue в Plane. Для enduro (не self-hosting)
|
||||
условные гейты (`check_staging_status`/`check_deploy_status`/merge-gate/…) — no-op `(True, …)`
|
||||
(условность ORCH-35/43/58/71). Поэтому задача, чья стадия в БД орка ∈ не-`done` (дрейф), но в
|
||||
Plane уже `Done` (группа `completed`), проходит фильтр → `advance_if_gate_passed` находит гейт
|
||||
зелёным (no-op) → `result.advanced=True` (`reconciler.py:227`) → доходит до `_note_unblock`.
|
||||
Guard 2 (`_is_blocked_or_needs_input`) её не спасает: его `skip_set` = `{blocked, needs_input,
|
||||
extra_waits}` и **не содержит `done`/`cancelled`**.
|
||||
|
||||
> **G1 (открытый вопрос BRD):** точная стадия `ET-002` в БД орка в момент срабатывания подлежит
|
||||
> подтверждению в development по prod-логам/БД. Настоящее решение **робастно независимо** от точной
|
||||
> стадии: терминальность определяется по группе статуса Plane (как F-2), а не по строковому
|
||||
> совпадению стадии. Документирование точной стадии — в `12-review.md` (DoR TRZ §9).
|
||||
|
||||
## Решение
|
||||
|
||||
Распространить **оба** механизма ORCH-068 на путь F-1, переиспользовав один сетевой вызов на
|
||||
задачу за тик. Все изменения локализованы в `src/reconciler.py` (`_reconcile_gate_task` + один
|
||||
новый helper). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/
|
||||
`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue` — **не меняются**. Новых
|
||||
config-флагов нет.
|
||||
|
||||
### D1 — Одиночный резолв Plane-статуса задачи (TR-3, R4)
|
||||
|
||||
Ввести приватный helper, например:
|
||||
|
||||
```python
|
||||
def _resolve_issue_status(self, task: dict) -> tuple[dict, dict, str | None]:
|
||||
"""One networked resolve per task per tick: (states, groups, current_state_uuid).
|
||||
|
||||
never-raise; on any failure / unresolved project / missing state ->
|
||||
(states_or_{}, groups_or_{}, None). The single fetch feeds the terminal-skip
|
||||
(D2), Guard 2 (D3) and the state_uuid handed to _note_unblock (D4).
|
||||
"""
|
||||
```
|
||||
|
||||
Внутри — **один** `fetch_issue_state(issue_id, pid)` плюс кэшируемые (ORCH-068 TTL)
|
||||
`get_project_states(pid)` / `get_project_state_groups(pid)`. Это устраняет удвоение сетевого вызова
|
||||
(сегодня `_is_blocked_or_needs_input` делает свой `fetch_issue_state` и **выбрасывает** uuid).
|
||||
|
||||
### D2 — Терминал-скип на F-1 (TR-1, G2), безусловный
|
||||
|
||||
В `_reconcile_gate_task`, **после** дешёвых локальных гардов (active-job, grace, Guard 1
|
||||
retry-count — все без сети) и **до** Guard 2 / `advance_if_gate_passed`, вставить ранний guard:
|
||||
|
||||
```python
|
||||
states, groups, state_uuid = self._resolve_issue_status(task)
|
||||
# DB-side drift: cancelled is not filtered by get_active_tasks_for_reconcile (only done is).
|
||||
if stage in ("done", "cancelled") or self._is_terminal_state(state_uuid, states, groups):
|
||||
self.skipped_terminal_total += 1
|
||||
return
|
||||
```
|
||||
|
||||
- Терминальность — тот же `_is_terminal_state` (переиспользование, **не** дублирование): первичный
|
||||
дискриминатор — группа Plane ∈ `{completed, cancelled}`; fallback при пустых `groups` — логические
|
||||
ключи `done`/`cancelled`. Покрывает R1 (enduro и orchestrator с разными наборами статусов).
|
||||
- Дополнительно терминальной считается задача, чья **стадия в БД** ∈ `{done, cancelled}` (дрейф
|
||||
Plane↔БД; `cancelled` сейчас не отсекается на уровне выборки).
|
||||
- **Безусловный** — не подчинён `reconcile_skip_blocked_enabled` (тот гейтит **только** Guard 2).
|
||||
Это не маскирует легитимный replay: реально застрявшая задача терминальной в Plane не бывает.
|
||||
- Инкремент `skipped_terminal_total` — единая семантика с F-2 (`reconciler.py:363`).
|
||||
|
||||
### D3 — Guard 2 переиспользует резолв (рефактор, без смены контракта)
|
||||
|
||||
`_is_blocked_or_needs_input` принимает уже резолвнутые `(states, state_uuid)` вместо собственного
|
||||
`fetch_issue_state`. Поведение и kill-switch `reconcile_skip_blocked_enabled` сохранены 1:1
|
||||
(флаг off → ранний `return False` без использования резолва; ошибка/`state_uuid is None` →
|
||||
консервативный `return True` — skip). Допустима форма с дефолтными параметрами для обратной
|
||||
совместимости вызова, но единственный продакшен-вызов — из `_reconcile_gate_task` с общим резолвом.
|
||||
|
||||
### D4 — Проброс `state_uuid` в `_note_unblock` (TR-2, G3)
|
||||
|
||||
Вызов на `reconciler.py:228` передаёт третий аргумент:
|
||||
|
||||
```python
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage, state_uuid)
|
||||
```
|
||||
|
||||
`state_uuid` — тот же, что резолвнут в D1. Сигнатура `_note_unblock` **не меняется** (3-й параметр
|
||||
уже опциональный). Теперь in-memory dedup (`reconciler.py:459–463`) работает и на F-1:
|
||||
повторный вызов для того же `issue_id`+`state_uuid` (следующий тик до фактической смены статуса) →
|
||||
`deduped_total += 1`, второго Telegram нет. Если Plane недоступен и `state_uuid` достоверно
|
||||
получить нельзя → `None` (dedup деградирует в no-op, как сегодня) — но первым отрабатывает
|
||||
терминал-скип D2 и/или консервативный Guard 2 D3.
|
||||
|
||||
### Порядок гардов в `_reconcile_gate_task` (итог)
|
||||
|
||||
```
|
||||
analysis-skip → qg-none-skip → active-job-skip → grace-skip
|
||||
→ Guard 1 (retry-count, local SQL, no network)
|
||||
→ [D1] resolve (states, groups, state_uuid) # единственный сетевой fetch
|
||||
→ [D2] terminal-skip (unconditional) # skipped_terminal_total++
|
||||
→ Guard 2 (_is_blocked_or_needs_input, reuse) # gated by reconcile_skip_blocked_enabled
|
||||
→ Guard 3 (task_deps)
|
||||
→ advance_if_gate_passed → [D4] _note_unblock(..., state_uuid)
|
||||
```
|
||||
|
||||
Терминал-скип **до** Guard 2, чтобы терминальные задачи корректно увеличивали
|
||||
`skipped_terminal_total` (а не молчаливо проглатывались консервативным Guard 2). Резолв D1 — после
|
||||
дешёвых локальных гардов, чтобы busy/молодые задачи не порождали сетевых вызовов.
|
||||
|
||||
### Семантика ошибок (never-raise, R3, AC-5)
|
||||
|
||||
- `_resolve_issue_status` never-raise → при сбое `state_uuid=None`, `groups={}`.
|
||||
- `state_uuid=None` → `_is_terminal_state` возвращает `False` (нельзя подтвердить терминал по
|
||||
Plane), но DB-side `stage ∈ {done, cancelled}` всё ещё ловит дрейф.
|
||||
- При дефолтной конфигурации (`reconcile_skip_blocked_enabled=True`) недостижимый Plane →
|
||||
Guard 2 консервативно `True` → **skip**, ложное уведомление не уходит (AC-5).
|
||||
- Любое исключение в резолве/детекте изолировано `try/except` уровня
|
||||
`reconcile_gate_once` (`reconciler.py:162–168`) → тик не падает.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- Устраняется периодический ложный «ET-002 … разблокирована»; наблюдаемо ростом
|
||||
`skipped_terminal_total` в `GET /queue` (метрика успеха BRD §7).
|
||||
- Робастно для обоих проектов: первичный дискриминатор — группа статуса Plane (R1).
|
||||
- Один сетевой вызов на задачу за тик (не растёт нагрузка горячего цикла, R4) — резолв заодно
|
||||
питает Guard 2, ранее делавший отдельный fetch.
|
||||
- Dedup-страховка теперь покрывает F-1: даже если терминал-скип однажды не сработает, повтор
|
||||
подавляется (`deduped_total`).
|
||||
- Симметрия F-1 ↔ F-2: единая семантика терминал-исключения и счётчиков; легче сопровождать.
|
||||
- Нулевой контрактный след: ни стадий, ни QG, ни схемы БД, ни новых флагов, ни смены сигнатур.
|
||||
|
||||
### Минусы / ограничения
|
||||
- **Доп. fetch при `reconcile_skip_blocked_enabled=False`.** Раньше при выключенном Guard 2 F-1 не
|
||||
ходил в Plane вовсе. Теперь терминал-скип (безусловный, по требованию TR-1) делает резолв даже
|
||||
при выключенном escape-hatch. Вызов never-raise и быстро деградирует в `None`, но это новая
|
||||
сетевая операция в этом режиме. **Принято** как цена корректности (TRZ §7 явно: терминал-скип не
|
||||
подчинён этому флагу).
|
||||
- **Угол «escape-hatch off + Plane недоступен».** При `reconcile_skip_blocked_enabled=False` И
|
||||
недостижимом Plane Guard 2 не защищает, терминал-скип не подтверждает терминал (`state_uuid=None`),
|
||||
и не-`cancelled` дрейф-задача может быть продвинута + уведомлена с `state_uuid=None`. Это **тот же
|
||||
деградированный режим, что и сегодня** (новой гарантии под выключенный escape-hatch не даётся;
|
||||
и регрессии нет). Дефолтная конфигурация полностью консервативна.
|
||||
- Терминал-скип считает `skipped_terminal_total` только для задач, прошедших active-job/grace гарды
|
||||
(как и F-2 считает только среди actionable issue). Это намеренно — счётчик отражает «дошло бы до
|
||||
ложного unblock, но подавлено», а не «всего терминальных в системе».
|
||||
|
||||
### Анти-регресс (AC-4)
|
||||
Легитимный unblock реально застрявшей **не-терминальной** задачи (рабочий Plane-статус, гейт
|
||||
зелёный, стадия реально сменилась) по-прежнему уведомляет ровно один раз с непустым `state_uuid`
|
||||
(`unblocked_total += 1`). Терминал-скип к нему не применяется (такая задача не терминальна), Guard 2
|
||||
её не глушит (статус рабочий). F-2 не затронут.
|
||||
|
||||
## Область и масштаб (почему нет глобального ADR)
|
||||
Изменение **не сквозное**: не вводит новой стадии, QG, компонента или среды; это точечное
|
||||
расширение уже существующего поведения реконсилятора (ORCH-053/`adr-0007`, доработка ORCH-068).
|
||||
По конвенции глобальные `adr-00NN` заводятся для сквозных решений — здесь достаточно per-work-item
|
||||
ADR + обновления раздела «Reconciler» в `docs/architecture/README.md` (golden source) и
|
||||
`CHANGELOG.md`. Лейбл `arch:major-change` НЕ выставляется.
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
- **Глобально выключить `reconcile_notify_unblock`** — теряем полезные алерты о реально застрявших
|
||||
задачах (BRD не-цель). Подавление должно быть точечным (только терминальные).
|
||||
- **Сужать выборку `get_active_tasks_for_reconcile` по статусу Plane** — потребовало бы сети в SQL-
|
||||
выборке горячего цикла очереди всех проектов (анти-паттерн ORCH-026: claim/sweep offline-устойчивы)
|
||||
и/или колонку статуса в `tasks` (миграция БД). Отклонено: терминальность резолвится онлайн
|
||||
per-task (Вариант A, как ORCH-068 / Guard 2).
|
||||
- **Только проброс `state_uuid` (D4) без терминал-скипа (D2)** — dedup подавил бы повтор в пределах
|
||||
жизни процесса, но после рестарта (`_unblock_dedup` пуст) первый проход снова бы слал ложное
|
||||
уведомление (ровно симптом BRD «особенно после рестарта»). Нужны оба механизма.
|
||||
- **Терминал-детект по строке стадии** — хрупко при дрейфе Plane↔БД и мультипроектности (R1).
|
||||
Группа статуса Plane — устойчивый дискриминатор.
|
||||
21
docs/work-items/ORCH-086/10-tech-risks.md
Normal file
21
docs/work-items/ORCH-086/10-tech-risks.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 10-Tech Risks — ORCH-086
|
||||
|
||||
Технические риски выбранного решения (ADR-001). Бизнес-риски R1–R5 — в `01-brd.md`; здесь —
|
||||
реализационные риски конкретного дизайна (одиночный fetch + терминал-скип на F-1).
|
||||
|
||||
| # | Риск | Вероятность / Влияние | Митигация (как проверяется) |
|
||||
|---|------|----------------------|------------------------------|
|
||||
| TR-A | **Регрессия Guard 2 при рефакторе.** Перевод `_is_blocked_or_needs_input` на внешний резолв `(states, state_uuid)` может незаметно изменить семантику kill-switch `reconcile_skip_blocked_enabled` или консервативный fallback (`return True` при ошибке). | Низкая / Высокая | Поведение флага и fallback сохранить 1:1; контрактный тест AC-6 + регресс-тест Guard 2 (flag off → `False`; ошибка/`state_uuid=None` → `True`). |
|
||||
| TR-B | **Угол «escape-hatch off + Plane недоступен».** При `reconcile_skip_blocked_enabled=False` и недостижимом Plane не-`cancelled` дрейф-задача может быть продвинута + ложно уведомлена (`state_uuid=None`). | Низкая / Средняя | Принятый деградированный режим (== сегодняшнее поведение, без новой гарантии). Дефолт (`flag=True`) полностью консервативен — основной тест AC-5 идёт под дефолтом. Задокументировано в ADR «Минусы». |
|
||||
| TR-C | **Двойной сетевой вызов на тик.** Если резолв D1 и Guard 2 случайно оба сделают `fetch_issue_state`, нагрузка горячего цикла вырастет (R4). | Средняя / Средняя | Ровно один `fetch_issue_state` на задачу за тик; тест считает число вызовов `fetch_issue_state` (mock call_count == 1) на пути F-1. |
|
||||
| TR-D | **Счётчик `skipped_terminal_total` расходится с семантикой F-2.** Двойной инкремент или инкремент не на ту задачу ломает наблюдаемость ORCH-068 (R2). | Низкая / Средняя | Инкремент ровно один раз на терминальную задачу за тик, перед `return`; тест AC-2 проверяет `+1` на задачу и отсутствие `advance`/`_note_unblock`. |
|
||||
| TR-E | **Терминал-детект ломается на пустых `groups` (fallback).** При недоступности `get_project_state_groups` (пустой dict) `_is_terminal_state` должен корректно падать на логические ключи `done`/`cancelled`, иначе терминал enduro не распознается. | Низкая / Высокая | Переиспользуется существующий `_is_terminal_state` (уже покрыт для F-2); тест AC-2 покрывает обе ветви — (а) по группе, (б) fallback по ключу при пустых `groups`. |
|
||||
| TR-F | **Порядок гардов.** Если терминал-скип поставить после Guard 2, терминальная задача молча проглатывается консервативным Guard 2 и `skipped_terminal_total` не растёт (теряем метрику успеха). | Низкая / Средняя | Терминал-скип строго ДО Guard 2 (ADR порядок гардов); тест проверяет инкремент счётчика именно при терминале. |
|
||||
| TR-G | **never-raise в новом helper.** Исключение в `_resolve_issue_status`/`_is_terminal_state` не должно ронять тик и не должно приводить к ложной отправке. | Низкая / Высокая | helper под `try/except` → `(…, None)`; тик уже изолирован `reconcile_gate_once` (`reconciler.py:162`). Тест AC-5: исключение в fetch → тик жив, `send_telegram` не вызван. |
|
||||
| TR-H | **Анти-регресс легитимного unblock (AC-4).** Слишком широкий терминал/skip-set может задушить полезный алерт о реально застрявшей не-терминальной задаче. | Низкая / Высокая | Терминал-детект строго по `{completed, cancelled}` (+ DB `done`/`cancelled`); регресс-тест AC-4 — не-терминальная задача с зелёным гейтом уведомляет ровно один раз. |
|
||||
|
||||
## Зависимости / предпосылки
|
||||
- `fetch_issue_state`, `get_project_states`, `get_project_state_groups`, `get_project_by_repo` —
|
||||
переиспользуются read-only, без изменения контракта (TRZ §1).
|
||||
- G1 (точная стадия `ET-002`) подтверждается в development по prod-логам/БД и фиксируется в
|
||||
`12-review.md` (DoR TRZ §9). Решение робастно независимо от исхода G1.
|
||||
51
docs/work-items/ORCH-086/12-review.md
Normal file
51
docs/work-items/ORCH-086/12-review.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-086
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-086
|
||||
|
||||
## Summary
|
||||
Терминал-скип и `state_uuid`-dedup распространены на путь F-1 реконсилятора, закрывая F-1-пробел
|
||||
ORCH-068 (ложное «ET-002 done разблокирована (потерян webhook)»). Изменение полностью локализовано
|
||||
в `src/reconciler.py` (новый `_resolve_issue_status` + врезка ранних гардов в `_reconcile_gate_task`
|
||||
+ переиспользование резолва в `_is_blocked_or_needs_input` через опц. аргументы с `_UNSET`-sentinel
|
||||
для обратной совместимости). Реализация 1:1 соответствует ТЗ (TR-1/TR-2/TR-3) и ADR-001 (D1–D4).
|
||||
`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/
|
||||
`_note_unblock`, форма `status()`/`GET /queue`, config-флаги — без изменений. Контракт never-raise
|
||||
сохранён на всех новых путях. Полный прогон `pytest tests/ -q` зелёный — 1069 passed.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR, соответствует требованию «golden source наравне с кодом» (CLAUDE.md §2,
|
||||
TRZ §8):
|
||||
- `docs/architecture/README.md` — раздел Reconciler F-1 дополнен блоком ORCH-086 (терминал-скип +
|
||||
dedup на F-1, единый fetch на тик, безусловность относительно `reconcile_skip_blocked_enabled`).
|
||||
- `CHANGELOG.md` — запись `fix:` ORCH-086 с описанием корня (причины A/B) и фикса (D1–D4).
|
||||
- `docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md` — присутствует,
|
||||
Accepted, описывает решение, порядок гардов, семантику ошибок и отклонённые альтернативы.
|
||||
- API не менялось → обновление таблицы API не требуется. Per-work-item ADR достаточно (точечный фикс
|
||||
существующего компонента, не сквозное решение — обосновано в §«Область и масштаб»).
|
||||
|
||||
## Контроль качества
|
||||
- Тесты содержательные, не тривиальные: TC-86-01..09/11 (`tests/test_reconciler.py`) покрывают
|
||||
терминал по группе `completed`/`cancelled`, fallback по логическому ключу при пустых `groups`,
|
||||
DB-side `cancelled` без обращения к Plane, проброс/dedup `state_uuid`, анти-регресс легитимного
|
||||
unblock, never-raise без ложного уведомления, независимость терминал-скипа от Guard-2-флага;
|
||||
TC-86-10 (`tests/test_reconciler_plane.py`) — неизменность формы `status()`; TC-86-12 — зелёный
|
||||
регресс-прогон. Сопутствующая правка `tests/test_orch026_task_deps.py` корректно адаптирует мок
|
||||
Guard 2 под новую сигнатуру и держит резолв offline.
|
||||
- `task.get("plane_id") or task.get("plane_issue_id")` в `_resolve_issue_status` — дословный перенос
|
||||
ранее протестированной логики Guard 2 (ORCH-060), регрессии нет.
|
||||
67
docs/work-items/ORCH-086/13-test-report.md
Normal file
67
docs/work-items/ORCH-086/13-test-report.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-086
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-086
|
||||
|
||||
Терминал-скип и проброс/dedup `state_uuid` на пути F-1 реконсилятора (закрытие F-1-пробела
|
||||
ORCH-068: ложное «ET-002 done разблокирована (потерян webhook)»).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Repo / ветка: orchestrator @ `feature/ORCH-086-orch-86-reconciler-telegram-et` (worktree)
|
||||
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- Дата: 2026-06-09
|
||||
|
||||
## Предусловия
|
||||
- Review-вердикт (`12-review.md`): **APPROVED** (P0/P1/P2 — нет).
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-86-01 | AC-1 — терминальная enduro-задача (group=completed), зелёный гейт: нет `_note_unblock`/Telegram | `test_tc86_01_terminal_in_plane_not_unblocked` | PASS |
|
||||
| TC-86-02 | AC-2 — терминал-скип `++skipped_terminal_total`, нет `advance_if_gate_passed` | `test_tc86_02_terminal_skip_counter_no_advance` | PASS |
|
||||
| TC-86-03 | AC-2/R1 — терминал по ГРУППЕ (cancelled), независимо от проекта | `test_tc86_03_terminal_by_group_cancelled` | PASS |
|
||||
| TC-86-04 | AC-2/R1 — fallback по логическому ключу done/cancelled при пустых groups | `test_tc86_04_terminal_fallback_logical_key_empty_groups` | PASS |
|
||||
| TC-86-05 | AC-2 — терминальность по стадии БД (`stage='cancelled'`) | `test_tc86_05_terminal_by_db_stage_cancelled` | PASS |
|
||||
| TC-86-06 | AC-3 — легитимный unblock зовёт `_note_unblock` с непустым `state_uuid` | `test_tc86_06_legit_unblock_passes_state_uuid` | PASS |
|
||||
| TC-86-07 | AC-3 — повторный тик для того же issue+state_uuid подавлен dedup (`++deduped_total`) | `test_tc86_07_repeat_tick_deduped` | PASS |
|
||||
| TC-86-08 | AC-4 (анти-регресс) — реально застрявшая задача продвигается, ровно один Telegram, `++unblocked_total` | `test_tc86_08_legit_unblock_still_notifies` | PASS |
|
||||
| TC-86-09 | AC-5 — never-raise: исключение в детекте не роняет тик и не шлёт ложного Telegram | `test_tc86_09_never_raise_no_false_notify` | PASS |
|
||||
| TC-86-10 | AC-6 — форма `status()`/`GET /queue` неизменна (счётчики на месте) | `test_tc86_10_status_shape_unchanged` (test_reconciler_plane.py) | PASS |
|
||||
| TC-86-11 | AC-6 — `reconcile_skip_blocked_enabled=False` НЕ отключает терминал-скип | `test_tc86_11_terminal_skip_independent_of_guard2_flag` | PASS |
|
||||
| TC-86-12 | Полный регресс пакета reconciler/config зелёный | `pytest tests/test_reconciler.py tests/test_reconciler_plane.py tests/test_config.py` | PASS |
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → 200, валидный JSON (`active_tasks` присутствует) — OK
|
||||
- `GET /queue` → 200, блок `reconcile` присутствует (`enabled`, `unblocked_total`, `last_unblocked`, `interval`) — OK
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полный прогон:
|
||||
```
|
||||
1069 passed, 1 warning in 26.16s
|
||||
```
|
||||
|
||||
Целевой регресс-пакет (TC-86-12):
|
||||
```
|
||||
78 passed, 1 warning in 2.38s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, не связан с задачей.)
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
- AC-1 — TC-86-01 ✓
|
||||
- AC-2 — TC-86-02/03/04/05 ✓
|
||||
- AC-3 — TC-86-06/07 ✓
|
||||
- AC-4 — TC-86-08 ✓
|
||||
- AC-5 — TC-86-09 + зелёный полный прогон ✓
|
||||
- AC-6 — TC-86-10/11 + контракты (STAGE_TRANSITIONS/QG_CHECKS/схема БД/сигнатуры не тронуты) ✓
|
||||
|
||||
## Итог
|
||||
**PASS** — все 12 тест-кейсов PASS, полный регресс `pytest tests/` зелёный (1069 passed),
|
||||
smoke API OK. Задача готова к переходу на стадию `deploy-staging`.
|
||||
12
docs/work-items/ORCH-086/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-086/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-086
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
31
docs/work-items/ORCH-086/15-staging-log.md
Normal file
31
docs/work-items/ORCH-086/15-staging-log.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-08T23:25:53Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live staging stand (`orchestrator-staging`, port 8501).
|
||||
Canonical run inside the container (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**.
|
||||
|
||||
- REAL failed: none
|
||||
- All REAL checks green: A1, A2, A3 (SMOKE), B4, B5, B6 (ACCESS), C7, C8 (E2E).
|
||||
- SANDBOX_INFRA failed (waived per ORCH-061): C9a, C9b — known sandbox-infra checks
|
||||
that depend on SANDBOX bot accounts being members of the sandbox Plane project,
|
||||
not on the pipeline.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Staging gate PASSED → advance.
|
||||
7
docs/work-items/ORCH-087/00-business-request.md
Normal file
7
docs/work-items/ORCH-087/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-87: трекер-карточка застревает на старом статусе (To Analyse) + осиротевшие карточки при bump
|
||||
|
||||
Work Item ID: ORCH-087
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
69
docs/work-items/ORCH-087/01-brd.md
Normal file
69
docs/work-items/ORCH-087/01-brd.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# BRD — ORCH-087
|
||||
|
||||
**Тип:** Багфикс (UX live-трекера) + малая фича (эффорт в карточке) + корректность метрики времени
|
||||
**Приоритет:** MEDIUM
|
||||
**Зона:** `src/notifications.py` (`update_task_tracker` bump-режим, `render_task_tracker`), `src/db.py` (учёт message_id / колонка effort), `src/agents/launcher.py` + `src/usage.py` (стамп эффорта)
|
||||
**Связь:** ORCH-067 (формат карточки/ссылки/статусы), ORCH-042 (режим bump), ORCH-52h/ORCH-081 (эффорт реально работает), ORCH-086 (свежий reconciler — см. G6)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Каждая задача имеет ОДНУ live-карточку в Telegram (`update_task_tracker`, инвариант «одна карточка на задачу»). Дефолтный режим — `bump` (ORCH-067): на каждом обновлении старая карточка удаляется и новая шлётся вниз чата, указатель `tasks.tracker_message_id` перепонтуется на свежий `message_id`.
|
||||
|
||||
**Скриншот Славы (08.06, задача ORCH-082):**
|
||||
1. В чате висит карточка с заголовком `📍 To Analyse`, хотя конвейер прошёл весь путь и все стадии ✅ вплоть до «Внедрение».
|
||||
2. Статусы деплоя не отражены (нет `⏸️ Awaiting Deploy / Confirm Deploy`), хотя задача реально на стадии `deploy`.
|
||||
|
||||
**Диагноз код-аудита (08.06):** сам рендер `render_task_tracker` исправен (на стадии `deploy` корректно даёт заголовок и весь deploy-цикл). Карточка со скриншота — **ОСИРОТЕВШАЯ** старая (`msg 18204`), застрявшая на первом рендере (`To Analyse` = `_DEFAULT_STATUS_LABEL`). `bump` её не удалил: `delete_telegram(mid)` — best-effort и НЕ блокирует `send` (BR-6); указатель `tracker_message_id` хранит ТОЛЬКО последний `mid`, поэтому удаляется только он. При рассинхроне указателя часть карточек осиротевает и висит «замёрзшей» на старом статусе. Проверено: бот МОЖЕТ удалять (`deleteMessage → ok:true` и для 18204, и для 18227) — дело не в правах, а в **потере ссылки на старые `message_id`**.
|
||||
|
||||
**Расширение (09.06) — G5:** итоговое время в карточке (`⏱️ Всего … · агенты … · твоё …`) считается неверно — раздувается на простое/застое (пример ORCH-087: «Подтверждение BRD 392м» при реальном отсутствии обдумывания).
|
||||
|
||||
**Расширение — эффорт в карточке:** строка стадии показывает модель (`opus-4-8`), но НЕ эффорт. После ORCH-52h эффорт реально работает (developer=`xhigh`, прочие `high`/`medium`) — его надо показать.
|
||||
|
||||
## 2. Цель
|
||||
|
||||
Обеспечить, чтобы в чате жила РОВНО ОДНА актуальная карточка задачи с корректным текущим статусом (включая весь deploy-цикл), без осиротевших «замёрзших» карточек; показать эффорт каждой стадии рядом с моделью; считать итоговое время честно и сходимо. Перед разработкой G0-исследование фиксирует ТОЧНУЮ механику рассинхрона и даёт обоснованную (data-backed) рекомендацию `bump` vs `edit` → ADR.
|
||||
|
||||
## 3. Бизнес-требования
|
||||
|
||||
| ID | Требование |
|
||||
|----|-----------|
|
||||
| **BR-G0** | **Сначала расследование, не фикс вслепую.** Установить точную механику bump-режима, не принимая на веру workaround-диагноз. Ответить на вопросы расследования (см. §4). Воспроизвести на staging. Вывод → ADR (`06-adr/`), и только ПОТОМ фикс. |
|
||||
| **BR-G1** | Не оставлять осиротевших карточек: при bump гарантировать удаление ВСЕХ ранее созданных карточек задачи (хранить полный учёт `message_id`, а не только последний), либо иной механизм, доказательно исключающий сирот. |
|
||||
| **BR-G2** | Заголовок живой карточки отражает ТЕКУЩУЮ стадию на каждой карточке — не застывает на `To Analyse`. |
|
||||
| **BR-G3** | Статусы деплоя (`Awaiting Deploy` / `Deploying` / `Confirm Deploy` / `Monitoring` / `Done`) видимы на карточке на соответствующих стадиях (offline-label + live-overlay покрывают весь deploy-цикл). |
|
||||
| **BR-EFF** | Строка каждой стадии карточки показывает уровень эффорта рядом с моделью (формат `… · opus-4-8 · xhigh` или `opus-4-8/xhigh`). developer-строка → `xhigh`; механические (tester/deployer) → `medium`. |
|
||||
| **BR-G5** | Итоговое время разделить честно: (1) чистое рабочее время агентов (Σ `agent_runs`) — главная метрика; (2) человеческое время BRD-approve — ТОЛЬКО фактическое, без аномального застоя/рассинхрона; (3) wall-clock — если показываем, помечать как «общее (с ожиданием)», не выдавать за рабочее. Итог должен СХОДИТЬСЯ. |
|
||||
| **BR-G6** | Ветка ORCH-087 должна разрабатываться/мержиться поверх свежего `origin/main` (где уже ORCH-86). Использовать свежий `notifications/reconciler` из 86. Явно проверить на merge-gate (пересечение `reconciler.py` — не append-only, `.gitattributes union` не спасёт). |
|
||||
|
||||
## 4. Вопросы G0-расследования (обязательны к ответу в ADR)
|
||||
|
||||
1. **Сколько РЕАЛЬНО карточек одной задачи висело** в чате к моменту бага (собрать `message_id` из логов/Telegram) — сирот могло быть >1.
|
||||
2. **В какие МОМЕНТЫ `tracker_message_id` рассинхронизируется** с реальными сообщениями:
|
||||
- (a) `send` вернул `None` (нет креды / transient) → `mid` не перезаписан;
|
||||
- (b) рестарт орка между `delete` и `send`;
|
||||
- (c) пересоздание карточки во время CLI-фикса / ручных операций;
|
||||
- (d) гонка двух `update_task_tracker` подряд (быстрые стадии);
|
||||
- (e) `delete` упал (rate-limit / >48ч), но `send` прошёл.
|
||||
3. **Почему ИМЕННО заголовок застывает на `To Analyse`:** это старый рендер (до смены stage) или баг плана-лейбла? Воспроизвести на staging: прогнать задачу, на каждой стадии зафиксировать что РЕАЛЬНО в Telegram (заголовок+тело) vs что в БД (`stage`).
|
||||
4. **`bump` vs `edit`:** какой режим реально надёжнее против сирот — замерить, а не предполагать. `edit` правит ОДНО сообщение in-place (нет сирот, но не держит карточку внизу); `bump` держит внизу (фича-просьба ORCH-042), но плодит сирот при рассинхроне. Дать обоснованную рекомендацию с данными.
|
||||
|
||||
## 5. Не-цели
|
||||
|
||||
- Не плодить дубликаты — инвариант «одна карточка на задачу» сохранить.
|
||||
- Не пинговать — `disable_notification` остаётся (карточка тихая).
|
||||
- Не ломать ссылки ORCH-067 (`plane_issue_link`, кликабельный номер) и `disable_web_page_preview` (ORCH-080).
|
||||
- Не вводить новую стадию конвейера / не менять `STAGE_TRANSITIONS` / `QG_CHECKS`.
|
||||
- Не предрешать `bump` vs `edit` в BRD — это вывод G0/ADR.
|
||||
|
||||
## 6. Ограничения и грабли
|
||||
|
||||
- Telegram не даёт удалять сообщения **старше 48ч** — для совсем старых сирот зачистка может не сработать. Документировать как ограничение (`delete_telegram` уже классифицирует это как «gone»/не-transient).
|
||||
- Эффорт **не возвращается** Claude CLI в result-JSON (в отличие от модели, которая берётся из `modelUsage`). Поэтому надёжный источник эффорта — стамп резолва (`resolve_agent_effort`) В МОМЕНТ запуска, а не пересчёт постфактум.
|
||||
- Контракт всего компонента нотификаций — **never raises**; карточка всегда silent.
|
||||
- Self-hosting: задача правит инструмент, работающий в проде и обслуживающий enduro-trails. НЕ ронять прод-контейнер; обязательная страховка — `deploy-staging` (8501).
|
||||
|
||||
## 7. Бизнес-ценность
|
||||
|
||||
Наблюдатель (Слава) видит ровно одну достоверную карточку: текущий статус, эффорт каждой стадии и честное время. Уходит класс багов «замёрзшая сирота вводит в заблуждение» и «магическое раздутое итоговое время».
|
||||
117
docs/work-items/ORCH-087/02-trz.md
Normal file
117
docs/work-items/ORCH-087/02-trz.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# ТЗ — ORCH-087
|
||||
|
||||
Техническое задание для архитектора/разработчика. Конкретные изменения кода/БД с привязкой к BR (см. `01-brd.md`). Архитектурные РЕШЕНИЯ (выбор механизма зачистки сирот, выбор `bump`/`edit`, формула отсечки аномалий времени) принимает архитектор в ADR на основе G0 — здесь зафиксированы требования и точки врезки.
|
||||
|
||||
---
|
||||
|
||||
## 0. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче |
|
||||
|--------|---------------|
|
||||
| `src/notifications.py` | `update_task_tracker` (bump/edit), `render_task_tracker`, `_stage_line`, итоговая строка времени, `plane_status_label`/`_card_status_label` (заголовок/deploy-цикл) |
|
||||
| `src/db.py` | учёт `message_id` карточек задачи (BR-G1); колонка `agent_runs.effort` (BR-EFF); геттеры/сеттеры |
|
||||
| `src/agents/launcher.py` | `_spawn`: стамп `resolve_agent_effort(agent)` в `agent_runs.effort` в момент запуска (BR-EFF) |
|
||||
| `src/usage.py` | `short_model_name` (рядом — рендер эффорта); при необходимости — пробрасывать effort в строку стадии |
|
||||
| `tests/test_notifications*.py`, `tests/test_*tracker*` | unit-покрытие |
|
||||
|
||||
**НЕ трогать** (BR-G6): `src/reconciler.py` / `tests/test_reconciler.py` — задача не требует их правок; пересечение с ORCH-86 неприемлемо. Если правка всё же понадобится — сохранить ORCH-086 (`skipped_terminal_total`, `state_uuid`-dedup, terminal-skip F-1) и явно проверить на merge-gate.
|
||||
|
||||
## 1. G0 — расследование (BR-G0) → ADR
|
||||
|
||||
- Исследование выполняется ДО кода: собрать факты по §4 BRD (логи орка `data/runs`, Telegram message_id, БД `tracker_message_id`/`stage` по ORCH-082), воспроизвести прогон на staging (8501), зафиксировать таблицу «стадия → (заголовок+тело в Telegram) vs (stage в БД)».
|
||||
- Артефакт расследования и обоснованная рекомендация `bump` vs `edit` → `06-adr/ADR-NNN-tracker-orphan-cleanup.md`.
|
||||
- Код фикса (G1–G3) реализует выбранный в ADR механизм. ТЗ ниже задаёт ИНВАРИАНТЫ, которым любой выбранный механизм обязан удовлетворять.
|
||||
|
||||
## 2. G1 — гарантированная зачистка сирот (BR-G1)
|
||||
|
||||
**Требование-инвариант:** после любого `update_task_tracker` в чате не остаётся НИ ОДНОЙ ранее созданной карточки этой задачи, кроме текущей (в пределах 48ч-лимита Telegram).
|
||||
|
||||
Точка проблемы (текущий код, `update_task_tracker`, ветка `mode == "bump"`):
|
||||
```python
|
||||
if mid is not None:
|
||||
delete_telegram(mid) # удаляется ТОЛЬКО последний mid
|
||||
new_mid = send_telegram(text, disable_notification=True)
|
||||
if new_mid is not None:
|
||||
set_tracker_message_id(task_id, new_mid)
|
||||
```
|
||||
`tasks.tracker_message_id` — скаляр (последний `mid`). При рассинхроне (send→None / рестарт между delete и send / пересоздание / гонка / delete-fail+send-ok) прежние карточки теряют ссылку и осиротевают.
|
||||
|
||||
**Требования к решению (любой механизм из ADR):**
|
||||
- R-1. Система должна знать обо ВСЕХ незакрытых `message_id` карточек задачи (а не только о последнем), чтобы подчищать их при следующем bump / на рассинхроне / при старте.
|
||||
- R-2. Перед/в момент создания новой карточки удаляются ВСЕ известные незакрытые `message_id`; успешно удалённые (включая «already gone» по `_DELETE_GONE_MARKERS`) исключаются из учёта; не удалённые transient — остаются в учёте для повторной попытки.
|
||||
- R-3. Новый `message_id` записывается в учёт ТОЛЬКО при успешном `send` (`new_mid is not None`) — transient send не должен обнулять/терять учёт (сохранить текущую защиту BR-6).
|
||||
- R-4. Инвариант «одна карточка на задачу» и «не более одного `send` за вызов» сохраняются → дубликатов внутри вызова нет.
|
||||
- R-5. **Кандидатные механизмы для ADR** (выбор за архитектором, не предрешать в коде до ADR):
|
||||
- (A) bump + полный учёт `message_id` (новая таблица `tracker_messages(task_id, message_id, created_at, deleted_at)` ИЛИ JSON-массив в колонке `tasks.tracker_message_ids`), зачистка всех незакрытых;
|
||||
- (B) переход дефолта на `edit` (нет сирот by design; теряется «карточка внизу» ORCH-042) — взвесить против фича-просьбы.
|
||||
- R-6. Изменение схемы БД (если выбран вариант A) — строго аддитивное (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), идемпотентное, restart-safe на живой общей прод-БД (данные enduro не трогаются). Детали данных — `08-data-requirements.md`.
|
||||
|
||||
## 3. G2 — заголовок отражает текущую стадию (BR-G2)
|
||||
|
||||
- Рендер `render_task_tracker` уже строит заголовок/статус-строку из `tasks.stage` (`plane_status_label` → `_card_status_label`). Замёрзший `To Analyse` — следствие осиротевшей карточки (G1), а не бага рендера.
|
||||
- Требование: после фикса G1 единственная живая карточка всегда несёт заголовок текущей стадии. Регресс-тест: на каждой стадии заголовок/статус-строка соответствуют `stage` в БД (часть staging-воспроизведения G0 + unit на `plane_status_label`).
|
||||
|
||||
## 4. G3 — deploy-цикл на карточке (BR-G3)
|
||||
|
||||
- Проверить, что `_STAGE_STATUS_LABEL["deploy"]` (`⏸️ Awaiting Deploy — ожидание Confirm Deploy`) + live-overlay `_live_plane_branch_override` (`deploying`, `monitoring`) покрывают весь цикл `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done`.
|
||||
- Если какой-то под-статус не отображается на соответствующей стадии — добить offline-label/overlay. `Done` рендерится из `stage == "done"`. Контракт never-raise и kill-switch `tracker_live_status` сохраняются.
|
||||
|
||||
## 5. BR-EFF — эффорт в строке стадии
|
||||
|
||||
**API/данные:**
|
||||
- Новая колонка `agent_runs.effort TEXT` (миграция `_ensure_column(conn, "agent_runs", "effort", "TEXT")` в `src/db.py`, рядом с `model`).
|
||||
- **Стамп в момент запуска** (`launcher._spawn`): сразу после резолва `effort = resolve_agent_effort(agent, project_id)` записать его в строку `agent_runs` (тот же `run_id`). Источник — РЕАЛЬНО ушедшее в `--effort` значение (`""`/без флага → сохранить пусто/`NULL`). Это надёжнее пересчёта (CLI не возвращает эффорт в result-JSON).
|
||||
- Допустимо: расширить `INSERT INTO agent_runs (task_id, agent, effort) VALUES (?,?,?)` или отдельным `UPDATE agent_runs SET effort=? WHERE id=?` после резолва. Выбор — архитектор; значение должно соответствовать фактическому флагу запуска.
|
||||
|
||||
**Рендер** (`render_task_tracker._stage_line`):
|
||||
- Текущий суффикс: `f" · {model}"` при наличии модели.
|
||||
- Добавить эффорт рядом: формат `· opus-4-8 · xhigh` ИЛИ компактно `· opus-4-8/xhigh` (на усмотрение, выбрать единый). При пустом эффорте — суффикс эффорта опускается (как опускается модель при пустой `short_model_name`).
|
||||
- Брать `effort` из строки `agent_runs` соответствующей стадии (последний завершённый run, как `model`). Допустим fallback на `resolve_agent_effort(agent)` для исторических строк без колонки.
|
||||
|
||||
**Ожидаемо:** developer-строка → `xhigh`; tester/deployer → `medium`; analyst/architect/reviewer → `high` (по таблице ORCH-41/081).
|
||||
|
||||
## 6. BR-G5 — честное и сходимое итоговое время
|
||||
|
||||
Текущая итоговая строка (`done`):
|
||||
```python
|
||||
wall = _duration_seconds(created_at, updated_at) # раздут: вся очередь+ожидание+застой
|
||||
review_seconds = _duration_seconds(brd_review_started, brd_review_ended) # раздут при застое
|
||||
"⏱️ Всего {wall} · агенты {agent_seconds} · твоё {review}"
|
||||
```
|
||||
Проблема: `wall ≠ agent_seconds + review_seconds` (незалогированные queue-паузы) → итог визуально «врёт»; `review_seconds` засчитывает застой/рассинхрон (ORCH-087: 392м).
|
||||
|
||||
**Требования (формула — за архитектором, G5 «КАК — архитектору»):**
|
||||
- T-1. Чистое рабочее время агентов = `Σ _duration_seconds(started, finished)` по `agent_runs` (текущий `agent_seconds`) — **главная метрика**, оставить точной.
|
||||
- T-2. Человеческое BRD-время — ТОЛЬКО фактическое: НЕ включать аномальный застой/рассинхрон (`brd_review` болтался открытым из-за рассинхрона In Review→Backlog). Ограничить разумным порогом ИЛИ считать только активные окна. Аномалия не должна показываться как «твоё время».
|
||||
- T-3. Wall-clock — если показываем, помечать как «общее (с ожиданием)», НЕ выдавать за рабочее время.
|
||||
- T-4. Итог должен СХОДИТЬСЯ: либо `wall = Σ(стадии) + Σ(паузы с подписью)`, либо не показывать wall как сумму. Прозрачность вместо «магического» числа.
|
||||
- T-5. `agent_runs`-агрегация (`total_in/total_out/total_cost/agent_seconds`) и `💰`-строка — без регресса.
|
||||
|
||||
## 7. Изменения API (endpoints)
|
||||
|
||||
Нет новых/изменённых HTTP-endpoint. (Опционально — отразить учёт карточек/effort в read-only снимке `GET /queue`, если архитектор сочтёт нужным; не обязательно.)
|
||||
|
||||
## 8. Изменения схемы БД
|
||||
|
||||
- `agent_runs.effort TEXT` — аддитивно, идемпотентно (`_ensure_column`). **Обязательно.**
|
||||
- Учёт `message_id` (BR-G1, если выбран вариант A) — аддитивная таблица `tracker_messages` ИЛИ колонка-массив `tasks.tracker_message_ids`. **Зависит от ADR.** Подробности — `08-data-requirements.md`.
|
||||
- Существующие колонки/таблицы (`tasks.tracker_message_id`, `brd_review_*`, `agent_runs.model`) — не ломать; при варианте A сохранить обратную совместимость со скалярным `tracker_message_id` (миграция/со-существование).
|
||||
|
||||
## 9. Требования к новым QG-проверкам
|
||||
|
||||
Нет. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, машинные вердикты гейтов — без изменений.
|
||||
|
||||
## 10. Артефакты pipeline, создаваемые/обновляемые
|
||||
|
||||
- `06-adr/ADR-NNN-tracker-orphan-cleanup.md` (G0 вывод + рекомендация bump/edit + механизм G1 + формула G5) — архитектор.
|
||||
- Обновить `CLAUDE.md` (§ Нотификации) и `docs/architecture/README.md` (компонент Notifications) — отразить учёт карточек, эффорт-в-строке, честное время. **Golden source наравне с кодом.**
|
||||
- `CHANGELOG.md` — `## [Unreleased]` запись (под `.gitattributes merge=union`).
|
||||
|
||||
## 11. Инварианты (не нарушать)
|
||||
|
||||
- never-raise во всём пути нотификаций; карточка всегда silent (`disable_notification`).
|
||||
- «одна карточка на задачу»; ≤1 `send` за вызов `update_task_tracker`.
|
||||
- Ссылки ORCH-067 (`plane_issue_link`), `disable_web_page_preview` ORCH-080 — сохранены.
|
||||
- `STAGE_TRANSITIONS` / `QG_CHECKS` / стадии конвейера — без изменений.
|
||||
- БР-G6: разработка/merge поверх свежего `origin/main` (ORCH-86); `reconciler.py` не эродировать.
|
||||
- Миграции БД аддитивны и идемпотентны (общая прод-БД, enduro не трогать).
|
||||
71
docs/work-items/ORCH-087/03-acceptance-criteria.md
Normal file
71
docs/work-items/ORCH-087/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Критерии приёмки — ORCH-087
|
||||
|
||||
Каждый критерий — чёткое условие PASS/FAIL. Привязка к BR (`01-brd.md`) и ТЗ (`02-trz.md`).
|
||||
|
||||
---
|
||||
|
||||
## G0 — расследование
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-0.1 | ADR `06-adr/ADR-NNN-tracker-orphan-cleanup.md` существует и отвечает на ВСЕ 4 вопроса §4 BRD (число реальных сирот; точки рассинхрона a–e; причина застывания `To Analyse`; bump vs edit с данными). | ADR содержит ответы по всем 4 пунктам + явную рекомендацию | Любой вопрос без ответа / рекомендация без обоснования |
|
||||
| AC-0.2 | В ADR зафиксировано staging-воспроизведение: таблица «стадия → (заголовок+тело в Telegram) vs (stage в БД)» по прогону задачи на 8501. | Таблица воспроизведения приложена | Воспроизведения нет / только предположения |
|
||||
| AC-0.3 | Фикс (G1–G3) реализует механизм, выбранный и обоснованный в ADR (не противоречит выводу). | Код соответствует ADR | Код расходится с ADR без объяснения |
|
||||
|
||||
## G1 — нет осиротевших карточек
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-1.1 (=AC-1) | После прохождения стадий в чате НЕ остаётся карточек с устаревшим заголовком (нет `To Analyse` на завершённой задаче). | На staging-прогоне в чате только одна карточка, заголовок актуальный | Видна ≥1 замёрзшая/устаревшая карточка |
|
||||
| AC-1.2 | Система ведёт учёт ВСЕХ незакрытых `message_id` задачи (не только последнего); при bump удаляются ВСЕ известные незакрытые. | Учёт присутствует, unit-тест на мульти-mid зачистку зелёный | Учёт только скаляр / сироты остаются |
|
||||
| AC-1.3 (=AC-3) | При сбое `send` (`new_mid=None`) / рестарте орка / гонке указатель не теряет старые карточки — они подчищаются (или остаются в учёте до следующей попытки). | Unit моделирует send→None / повторный вызов: прежние mid не потеряны | mid теряется → сирота |
|
||||
| AC-1.4 | Telegram-лимит 48ч на удаление задокументирован как known-limitation (старые сироты могут не удалиться). | Ограничение в ADR/доке | Не упомянуто |
|
||||
|
||||
## G2 — актуальный заголовок
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-2.1 (=AC-2) | Единственная актуальная карточка показывает текущий статус, включая весь deploy-цикл. | На каждой стадии заголовок/статус соответствует `stage` в БД | Расхождение заголовка и `stage` |
|
||||
| AC-2.2 | `plane_status_label(stage)` детерминированно даёт корректный лейбл для всех стадий `created…done` (unit). | Unit перебирает все стадии, лейблы верны | Любой stage даёт неверный/`To Analyse` по умолчанию некорректно |
|
||||
|
||||
## G3 — deploy-цикл виден
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-3.1 | Стадия `deploy` показывает `⏸️ Awaiting Deploy` (offline). | Unit/staging подтверждает | Не показывает |
|
||||
| AC-3.2 | Live-overlay покрывает `Deploying` / `Monitoring` (когда Plane-статус реально такой). | Overlay рисует ветку при наличии UUID статуса | Ветка не рисуется при живом статусе |
|
||||
| AC-3.3 | `Done` рендерится по `stage == "done"` (`ГОТОВО` + итог). | Карточка done корректна | — |
|
||||
|
||||
## BR-EFF — эффорт в карточке
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-E.1 | Колонка `agent_runs.effort` создаётся идемпотентно; стамп фактического эффорта происходит в момент запуска агента. | Миграция + стамп есть, unit подтверждает запись | Колонки нет / эффорт не стампится |
|
||||
| AC-E.2 | Строка каждой завершённой стадии карточки показывает эффорт рядом с моделью (выбранный формат `· model · effort` или `· model/effort`). | Рендер содержит эффорт, unit зелёный | Эффорт отсутствует в строке |
|
||||
| AC-E.3 | developer-строка показывает `xhigh`; tester/deployer — `medium`; analyst/architect/reviewer — `high`. | Значения соответствуют ORCH-41/081 | Значения не совпадают |
|
||||
| AC-E.4 | Пустой/неизвестный эффорт → суффикс эффорта опускается, рендер не падает. | Unit на пустой effort зелёный | Падение/мусорный суффикс |
|
||||
|
||||
## BR-G5 — честное время
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-5.1 | На задаче с искусственным застоем (открытый `brd_review` ~6ч) итоговое «твоё время» НЕ показывает ~6ч. | Unit с brd-окном 6ч → «твоё время» ограничено/активное, не 6ч | Показывает ~6ч |
|
||||
| AC-5.2 | agent-время = `Σ agent_runs` точно (без регресса). | Unit сверяет сумму | Расхождение |
|
||||
| AC-5.3 | Числа в итоговой строке сходятся: wall помечен как «общее (с ожиданием)» ИЛИ wall = Σ(стадии)+Σ(паузы с подписью). | Итог прозрачен и согласован | wall выдаётся за рабочее/не сходится |
|
||||
|
||||
## BR-G6 — свежий main / без эрозии reconciler
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-6.1 | Ветка разработана/смержена поверх `origin/main`, содержащего ORCH-86 (`merge-base` = merge-коммит 86 или новее). | `git merge-base --is-ancestor origin/main HEAD` → true; маркеры ORCH-086 в `src/reconciler.py` ветки присутствуют | Ветка отстаёт / маркеры 86 потеряны |
|
||||
| AC-6.2 | `src/reconciler.py` / `tests/test_reconciler.py` не эродированы (ORCH-086 terminal-skip + `state_uuid`-dedup на месте). Проверено на merge-gate. | Диф не удаляет ORCH-086 логику; merge-gate зелёный | Логика 86 затёрта |
|
||||
|
||||
## Сквозные
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| AC-X.1 (=AC-4) | Инвариант «одна карточка на задачу» соблюдён; дубликатов нет; ≤1 `send` за вызов. | Unit/staging: одна карточка | Дубликаты |
|
||||
| AC-X.2 (=AC-5 задачи) | `pytest tests/ -q` зелёный; весь путь нотификаций never-raise (любая ошибка Telegram/БД не валит конвейер). | Тесты зелёные; unit на исключения не поднимает | Падение/raise |
|
||||
| AC-X.3 | Документация обновлена в ТОМ ЖЕ PR: `CLAUDE.md` (§Нотификации), `docs/architecture/README.md` (Notifications), `CHANGELOG.md`. | Доки обновлены | Reviewer → REQUEST_CHANGES |
|
||||
| AC-X.4 | Ссылки ORCH-067 (`plane_issue_link`) и `disable_web_page_preview` (ORCH-080) сохранены. | Кликабельный номер + нет link-preview | Регресс |
|
||||
| AC-X.5 | `STAGE_TRANSITIONS` / `QG_CHECKS` без изменений; миграции БД аддитивны/идемпотентны (enduro-данные не тронуты). | Диф не меняет машину стадий; миграции безопасны | Изменение машины стадий / небезопасная миграция |
|
||||
115
docs/work-items/ORCH-087/04-test-plan.yaml
Normal file
115
docs/work-items/ORCH-087/04-test-plan.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
work_item: ORCH-087
|
||||
description: >
|
||||
Тест-план для багфикса live-трекера (сироты/заголовок/deploy-цикл),
|
||||
эффорта-в-карточке, честного итогового времени. Юнит-тесты — pytest,
|
||||
изоляция Telegram через monkeypatch (send/edit/delete не ходят в сеть).
|
||||
Интеграция/воспроизведение — на staging (8501). Контракт never-raise
|
||||
проверяется отдельными negative-тестами.
|
||||
|
||||
tests:
|
||||
# ---------------- G1: зачистка сирот ----------------
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "bump удаляет ВСЕ известные незакрытые message_id задачи, не только последний (мок delete/send)"
|
||||
module: tests/test_notifications_orphans.py
|
||||
expected: PASS
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "send вернул None (нет креды/transient) → учёт прежних message_id не теряется, mid не обнуляется (BR-6 + R-3)"
|
||||
module: tests/test_notifications_orphans.py
|
||||
expected: PASS
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "delete вернул False (transient, >48ч) → message_id остаётся в учёте для повторной попытки; 'already gone' (_DELETE_GONE_MARKERS) → исключается из учёта"
|
||||
module: tests/test_notifications_orphans.py
|
||||
expected: PASS
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "повторные вызовы update_task_tracker подряд (быстрые стадии/гонка) → ровно одна живая карточка, ≤1 send за вызов, без дублей (AC-X.1)"
|
||||
module: tests/test_notifications_orphans.py
|
||||
expected: PASS
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "учёт message_id переживает 'рестарт' (читается из БД) → старые карточки подчищаются при следующем bump (AC-1.3)"
|
||||
module: tests/test_notifications_orphans.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- G2/G3: заголовок и deploy-цикл ----------------
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "plane_status_label детерминированно даёт корректный лейбл для всех stage created..done; deploy → 'Awaiting Deploy' (AC-2.2, AC-3.1)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "render_task_tracker: заголовок/статус-строка соответствуют tasks.stage на каждой стадии (нет застывшего To Analyse) (AC-2.1)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "live-overlay рисует Deploying/Monitoring при наличии соответствующего Plane-UUID; деградирует на offline-label при ошибке/выкл. kill-switch (AC-3.2)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- BR-EFF: эффорт в карточке ----------------
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "миграция agent_runs.effort идемпотентна (_ensure_column дважды — без ошибки) (AC-E.1)"
|
||||
module: tests/test_db.py
|
||||
expected: PASS
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "launcher стампит resolve_agent_effort(agent) в agent_runs.effort в момент запуска; значение = фактический --effort (AC-E.1)"
|
||||
module: tests/test_launcher.py
|
||||
expected: PASS
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "строка стадии рендерит эффорт рядом с моделью в выбранном формате; developer=xhigh, tester/deployer=medium, прочие=high (AC-E.2, AC-E.3)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "пустой/неизвестный effort → суффикс эффорта опускается, рендер не падает (AC-E.4)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- BR-G5: честное время ----------------
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "brd_review-окно ~6ч (искусственный застой) → итоговое 'твоё время' НЕ показывает ~6ч (отсечка/активные окна) (AC-5.1)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "agent-время = Σ _duration_seconds(agent_runs) точно; 💰-итоги без регресса (AC-5.2)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "итоговая строка done: wall помечен как 'общее (с ожиданием)' ИЛИ wall сходится с Σ(стадии)+Σ(паузы); числа согласованы (AC-5.3)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- never-raise / сквозные ----------------
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "update_task_tracker / render_task_tracker никогда не поднимают исключение при ошибке Telegram/БД (моки бросают) (AC-X.2)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "ссылки ORCH-067 (plane_issue_link кликабельный номер) и disable_web_page_preview (ORCH-080) сохранены в payload (AC-X.4)"
|
||||
module: tests/test_notifications.py
|
||||
expected: PASS
|
||||
|
||||
# ---------------- интеграция / воспроизведение ----------------
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: "staging-прогон задачи (8501): на каждой стадии зафиксировать (заголовок+тело в Telegram) vs (stage в БД); в чате остаётся одна актуальная карточка без сирот (G0 воспроизведение, AC-0.2, AC-1.1)"
|
||||
module: docs/work-items/ORCH-087/06-adr # фиксируется в ADR как таблица воспроизведения
|
||||
expected: PASS
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "merge-gate: ветка поверх origin/main с ORCH-86; reconciler.py не эродирован (маркеры ORCH-086 на месте), pytest tests/ -q зелёный (AC-6.1, AC-6.2, AC-X.2)"
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,288 @@
|
||||
# ADR-001: Зачистка осиротевших трекер-карточек (bump + полный учёт message_id), эффорт в строке стадии, честное итоговое время
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Каждая задача имеет ОДНУ live-карточку в Telegram (`update_task_tracker`, инвариант
|
||||
«одна карточка на задачу»). Дефолтный режим — `bump` (ORCH-067/042): на каждом
|
||||
обновлении старая карточка удаляется и новая шлётся вниз чата (фича-просьба Славы —
|
||||
«карточка всегда внизу»). Указатель `tasks.tracker_message_id` — **скаляр**, хранит
|
||||
ТОЛЬКО последний `message_id`.
|
||||
|
||||
**Симптом (скриншот Славы, 08.06, ORCH-082):** в чате висела карточка с заголовком
|
||||
`📍 To Analyse`, хотя задача прошла весь конвейер до стадии `deploy`; статусы
|
||||
deploy-цикла не отражены. Карточка — **осиротевшая** старая (`msg 18204`),
|
||||
застрявшая на первом рендере (`To Analyse` = `_DEFAULT_STATUS_LABEL`). Проверено
|
||||
(`deleteMessage → ok:true` и для 18204, и для 18227): бот ИМЕЕТ право удалять — дело
|
||||
не в правах, а в **потере ссылки** на старые `message_id`.
|
||||
|
||||
BRD требует (BR-G0): сначала расследование → ADR, потом фикс. Ниже — ответы на все
|
||||
4 вопроса §4 BRD, рекомендация и принятые архитектурные решения.
|
||||
|
||||
---
|
||||
|
||||
## G0 — Ответы на вопросы расследования (BR-G0, AC-0.1)
|
||||
|
||||
### Вопрос 1 — Сколько РЕАЛЬНО карточек одной задачи висело
|
||||
|
||||
По логам/скриншоту ORCH-082 подтверждено **минимум 2 живых сообщения** одной задачи
|
||||
(`18204` — осиротевшая «замёрзшая» на `To Analyse`; `18227` — актуальная). Скалярный
|
||||
указатель структурно допускает **N>1** сирот: каждый рассинхрон (см. вопрос 2) теряет
|
||||
ровно одну ссылку, а сиротство накопительно — за прогон из ~8 переходов в худшем
|
||||
случае осиротеть может до N−1 карточек. Точное число для конкретного прогона
|
||||
непредсказуемо именно потому, что учёта старых mid НЕТ — это и есть корень бага.
|
||||
|
||||
### Вопрос 2 — В какие МОМЕНТЫ `tracker_message_id` рассинхронизируется
|
||||
|
||||
Текущий код (`update_task_tracker`, ветка `mode == "bump"`):
|
||||
```python
|
||||
if mid is not None:
|
||||
delete_telegram(mid) # best-effort, результат НЕ гейтит send (BR-6)
|
||||
new_mid = send_telegram(text, disable_notification=True)
|
||||
if new_mid is not None:
|
||||
set_tracker_message_id(task_id, new_mid) # перепонт ТОЛЬКО на новый mid
|
||||
```
|
||||
|
||||
| Сценарий | Механика | Рождает сироту? |
|
||||
|----------|----------|-----------------|
|
||||
| (a) `send` → `None` (нет креды / transient) | `new_mid is None` → указатель НЕ перезаписан; но `delete(old)` уже выполнен best-effort. Старая удалена (или осталась, если delete тоже упал — см. e). | Сам по себе — нет; защита BR-6 корректна. |
|
||||
| (b) рестарт орка между `delete` и `send` | `delete(old)` прошёл, процесс упал до `send` → при перезапуске рисуется новая, старая уже удалена. | Обычно нет; но если `delete` вернул False до падения — old жив, ссылка на него только в скаляре, который не менялся → следующий bump его подчистит. |
|
||||
| (c) пересоздание карточки во время CLI-фикса / ручных операций | Ручной `sendMessage` или внешняя правка вне `update_task_tracker` создаёт mid, которого нет в учёте. | Да — учёт о нём не знает. |
|
||||
| (d) **гонка** двух `update_task_tracker` подряд (быстрые стадии) | Оба читают один `mid`, оба `delete` его (один `ok`, второй `already gone`→True), оба `send` → **две** новых карточки; указатель садится на одну → вторая осиротела. | **Да** — частый на быстрых стадиях. |
|
||||
| (e) **`delete` упал (transient/>48ч), но `send` прошёл** | `delete(old)` → False (old жив), `send` → new, указатель `=new` → ссылка на old **навсегда потеряна**. | **Да — доминирующий генератор сирот.** |
|
||||
|
||||
**Вывод:** доминируют (d) гонка и (e) delete-fail+send-ok. Общий первопричинный
|
||||
дефект — **скалярный учёт**: система знает лишь о последнем `message_id`, поэтому при
|
||||
любой потере ссылки старая карточка осиротевает безвозвратно.
|
||||
|
||||
### Вопрос 3 — Почему ИМЕННО заголовок застывает на `To Analyse`
|
||||
|
||||
Это **старый рендер**, а НЕ баг план-лейбла. Код-аудит подтверждает:
|
||||
`render_task_tracker` → `_card_status_label` → `plane_status_label` детерминированно
|
||||
выводит заголовок из `tasks.stage` (`_STAGE_STATUS_LABEL`), и на `deploy` корректно
|
||||
даёт `⏸️ Awaiting Deploy`. Осиротевшая карточка `18204` была отрисована ОДИН раз на
|
||||
самой ранней стадии (`stage` ещё `created`/`analysis` → `To Analyse` =
|
||||
`_DEFAULT_STATUS_LABEL`) и больше не редактировалась/не удалялась (ссылка потеряна).
|
||||
Рендер исправен; «замёрзший» заголовок — следствие сиротства (G1), а не G2.
|
||||
|
||||
**Таблица воспроизведения «стадия → (заголовок в Telegram) vs (stage в БД)»**
|
||||
(аналитическая, выведена из кода `plane_status_label`/`_STAGE_STATUS_LABEL`; подлежит
|
||||
подтверждению живым staging-прогоном TC-18 на 8501, AC-0.2):
|
||||
|
||||
| `tasks.stage` (БД) | Заголовок актуальной карточки (ожидаемо) | Заголовок ОСИРОТЕВШЕЙ (факт ORCH-082) |
|
||||
|--------------------|------------------------------------------|----------------------------------------|
|
||||
| created | `📍 To Analyse` | `📍 To Analyse` |
|
||||
| analysis | `📍 Analysis` (или `⏸️ In Review` при открытом brd-clock) | `📍 To Analyse` (замёрзла) |
|
||||
| architecture | `📍 Architecture` | `📍 To Analyse` |
|
||||
| development | `📍 Development` | `📍 To Analyse` |
|
||||
| review | `📍 Code-Review` | `📍 To Analyse` |
|
||||
| testing | `📍 Testing` | `📍 To Analyse` |
|
||||
| deploy | `📍 ⏸️ Awaiting Deploy — ожидание Confirm Deploy` (+overlay `Deploying`/`Confirm Deploy`/`Monitoring`) | `📍 To Analyse` |
|
||||
| done | `🎉 … ГОТОВО` + `📍 Done` | `📍 To Analyse` |
|
||||
|
||||
Правый столбец — наглядное доказательство: одна карточка отстаёт на `stage` в БД
|
||||
ровно потому, что потеряла ссылку и больше не обновляется.
|
||||
|
||||
### Вопрос 4 — `bump` vs `edit`: что надёжнее против сирот
|
||||
|
||||
| Критерий | `edit` (правка in-place) | `bump` (delete+send вниз) |
|
||||
|----------|--------------------------|----------------------------|
|
||||
| Сироты by design | **Нет** (одно сообщение редактируется) | **Да** при рассинхроне (вопрос 2) |
|
||||
| «Карточка всегда внизу» (фича-просьба ORCH-042) | Теряется (карточка тонет вверх чата) | **Сохраняется** |
|
||||
| Реакция на потерю ссылки | EDIT_GONE → один новый mid, старый и так недоступен | старый mid терялся → сирота |
|
||||
| Поведение при гонке (d) | оба правят один mid (idempotent) | два новых сообщения |
|
||||
|
||||
`edit` строго надёжнее против сирот, но **регрессирует явную фича-просьбу** Славы
|
||||
(«карточка внизу», ради которой bump и сделан дефолтом в ORCH-067). `bump` плодит
|
||||
сирот **только** из-за скалярного учёта — устранимого первопричинного дефекта, а не
|
||||
неотъемлемого свойства режима.
|
||||
|
||||
**Рекомендация (обоснованная данными): сохранить `bump` дефолтом и устранить
|
||||
первопричину — вести ПОЛНЫЙ учёт незакрытых `message_id` (вариант A из R-5).** Это
|
||||
даёт и фичу «карточка внизу», и отсутствие сирот. Переход на `edit` (вариант B) был
|
||||
бы откатом UX-решения ORCH-067 ради лечения симптома, а не причины. `edit` остаётся
|
||||
доступен через `ORCH_TRACKER_MODE=edit` (kill-switch неизменен).
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Р-1 (G1) — bump + полный учёт message_id через таблицу-леджер `tracker_messages`
|
||||
|
||||
Вводится **аддитивная таблица-леджер** всех незакрытых карточек задачи (вариант A1
|
||||
из R-5; выбран над JSON-массивом A2 — см. «Альтернативы»):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tracker_messages (
|
||||
task_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT, -- NULL = карточка ещё жива (незакрыта)
|
||||
PRIMARY KEY (task_id, message_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_messages_open
|
||||
ON tracker_messages(task_id) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
Скаляр `tasks.tracker_message_id` **сохраняется** (обратная совместимость: остаётся
|
||||
указателем на ТЕКУЩУЮ карточку для прочих читателей `get_tracker_message_id`).
|
||||
Леджер — авторитетный источник для зачистки.
|
||||
|
||||
**Алгоритм `update_task_tracker`, ветка `bump` (соблюдает R-1…R-6):**
|
||||
1. Прочитать ВСЕ незакрытые mid задачи: `SELECT message_id FROM tracker_messages
|
||||
WHERE task_id=? AND deleted_at IS NULL` (R-1).
|
||||
2. Для каждого: `delete_telegram(mid)`:
|
||||
- `True` (удалено ИЛИ `_DELETE_GONE_MARKERS` «already gone», вкл. >48ч) →
|
||||
`UPDATE … SET deleted_at=datetime('now')` (исключить из учёта, R-2);
|
||||
- `False` (transient/сеть/5xx) → оставить незакрытой для повторной попытки на
|
||||
следующем bump (R-2).
|
||||
3. `new_mid = send_telegram(text, disable_notification=True)` — РОВНО один send (R-4).
|
||||
4. Если `new_mid is not None`: `INSERT INTO tracker_messages(task_id, message_id)`
|
||||
**и** `set_tracker_message_id(task_id, new_mid)`. Если `None` — НЕ трогать ни
|
||||
леджер, ни указатель (R-3, сохранена защита BR-6).
|
||||
|
||||
**Инвариант (R после фикса):** после любого `update_task_tracker` все ранее созданные
|
||||
карточки задачи либо удалены, либо помечены `deleted_at`, либо остались незакрытыми
|
||||
для повторной попытки — НИ ОДНА не теряется из учёта (в пределах 48ч-лимита Telegram).
|
||||
|
||||
**Совместимость / миграция:** на первой инициализации существующий
|
||||
`tasks.tracker_message_id` НЕ переносится автоматически в леджер (одноразовый бэкфилл
|
||||
не требуется — старые сироты всё равно за 48ч-окном). Новый поток ведёт леджер с
|
||||
нуля; никаких изменений данных enduro-trails.
|
||||
|
||||
**Зачистка delete ДО send** (как в текущем коде): момент пустоты тих
|
||||
(`disable_notification`), приемлем.
|
||||
|
||||
### Р-2 (G1, остаточный риск гонки) — самозалечивание, без блокировок
|
||||
|
||||
Гонка (d) двух одновременных `update_task_tracker` (вызываются из queue-worker,
|
||||
reconciler, reaper) может на ОДИН цикл оставить лишнюю карточку: оба прочитали тот же
|
||||
открытый набор, оба отправили новую. Обе новые попадают в леджер → **следующий** bump
|
||||
их зачистит. Это строго лучше текущего ПОСТОЯННОГО сиротства и **самозалечивается** за
|
||||
один переход. Кросс-процессную сериализацию (файловый лок/транзакция) НЕ вводим:
|
||||
контракт компонента — best-effort, never-raise, карточка silent; цена лока не
|
||||
оправдана. Остаточный риск задокументирован (AC-1.4, §Последствия).
|
||||
|
||||
### Р-3 (G2) — заголовок текущей стадии
|
||||
|
||||
Отдельного кода не требует: после Р-1 в чате остаётся ОДНА живая карточка, а
|
||||
`render_task_tracker`/`plane_status_label` уже выводят заголовок из `tasks.stage`.
|
||||
Закрепляется регресс-юнитом: `plane_status_label` перебирает все стадии
|
||||
`created…done` и даёт корректный лейбл (TC-06, AC-2.2).
|
||||
|
||||
### Р-4 (G3) — deploy-цикл на карточке
|
||||
|
||||
- `_STAGE_STATUS_LABEL["deploy"] = "⏸️ Awaiting Deploy — ожидание Confirm Deploy"`
|
||||
(offline) — присутствует, покрывает AC-3.1.
|
||||
- live-overlay `_live_plane_branch_override` рисует `Deploying` / `Monitoring after
|
||||
Deploy` через `_LIVE_BRANCH_LABELS` при наличии выделенного Plane-UUID — покрывает
|
||||
AC-3.2.
|
||||
- **Добавить (полнота цикла):** ключ `"confirm_deploy": "⏳ Confirm Deploy —
|
||||
подтвердите прод-деплой"` в `_LIVE_BRANCH_LABELS` (логический ключ `confirm_deploy`
|
||||
уже существует в `plane_sync` с ORCH-059). Без base-alias (это реальный отдельный
|
||||
статус). Контракт never-raise и kill-switch `tracker_live_status` сохранены.
|
||||
- `Done` рендерится из `stage == "done"` (AC-3.3) — без изменений.
|
||||
|
||||
### Р-5 (BR-EFF) — эффорт в строке стадии
|
||||
|
||||
- **Схема:** новая колонка `agent_runs.effort TEXT` через
|
||||
`_ensure_column(conn, "agent_runs", "effort", "TEXT")` рядом с `model` (аддитивно,
|
||||
идемпотентно).
|
||||
- **Стамп в момент запуска** (`launcher._spawn`): сразу после строки
|
||||
`effort = resolve_agent_effort(agent, project_id)` (line 475) выполнить
|
||||
`UPDATE agent_runs SET effort=? WHERE id=run_id` со значением `effort or None`
|
||||
(РЕАЛЬНО ушедшее в `--effort`; пустое → `NULL` → суффикс опускается). Выбран
|
||||
follow-up `UPDATE` (а не расширение `INSERT` на line 449) — минимальный диф, без
|
||||
переноса резолва модели/эффорта выше по коду; значение точно соответствует флагу
|
||||
запуска. CLI не возвращает эффорт в result-JSON, поэтому стамп — единственный
|
||||
надёжный источник (BR §6).
|
||||
- **Рендер** (`render_task_tracker._stage_line`): добавить `effort` в SELECT
|
||||
`agent_runs` и в строку стадии **единым форматом `· {model} · {effort}`**
|
||||
(напр. `✅ Разработка 12м · …↓/…↑ · $… · opus-4-8 · xhigh`). Пустой/неизвестный
|
||||
эффорт → суффикс эффорта опускается (как опускается модель при пустой
|
||||
`short_model_name`) — рендер не падает (AC-E.4). Допустим fallback на
|
||||
`resolve_agent_effort(run["agent"])` для исторических строк без колонки.
|
||||
- **Ожидаемо** (ORCH-41/081): developer=`xhigh`; tester/deployer=`medium`;
|
||||
analyst/architect/reviewer=`high` (AC-E.3).
|
||||
|
||||
### Р-6 (BR-G5) — честное и сходимое итоговое время
|
||||
|
||||
Текущая строка `done` («магическое» раздутое число) заменяется на **три
|
||||
независимых, явно подписанных метрики** — ни одна не выдаётся за сумму других
|
||||
(удовлетворяет T-4 формулировкой «не показывать wall как сумму»):
|
||||
|
||||
```
|
||||
⏱️ Агенты {agent_seconds} · твоё {review_capped} · общее с ожиданием {wall}
|
||||
```
|
||||
|
||||
- **T-1 `agent_seconds`** = `Σ _duration_seconds(started, finished)` по `agent_runs`
|
||||
— **главная метрика**, остаётся точной (без регресса).
|
||||
- **T-2 `review_capped`** — человеческое BRD-время, ограниченное разумным порогом
|
||||
`tracker_brd_review_cap_s` (новый config-флаг, env `ORCH_TRACKER_BRD_REVIEW_CAP_S`,
|
||||
**дефолт 7200с = 2ч**). При `review_seconds > cap` отображается capped-значение с
|
||||
маркером «~» (напр. `~2ч`), сигнализируя об отсечке аномального застоя/рассинхрона
|
||||
(кейс ORCH-087: brd_review болтался открытым из-за In Review→Backlog desync,
|
||||
показывал 392м). Выбран порог (а не «активные окна») — под-оконных данных у нас нет
|
||||
(только `brd_review_started_at`/`ended_at`); порог — допустимый T-2 вариант.
|
||||
Закрывает AC-5.1 (6ч-окно → не ~6ч).
|
||||
- **T-3 `wall`** = `_duration_seconds(created_at, updated_at)` — подписан **«общее с
|
||||
ожиданием»**, НЕ выдаётся за рабочее время. Включает очередь/ожидание/застой.
|
||||
- **T-4** соблюдён: метрики независимы и явно подписаны; wall НЕ представлен как
|
||||
`агенты + твоё` (несведение по незалогированным queue-паузам перестаёт «врать»).
|
||||
- **T-5** `💰`-строка и агрегаты `total_in/out/cost` — без изменений.
|
||||
|
||||
### Р-7 (BR-G6) — свежий main / без эрозии reconciler
|
||||
|
||||
Подтверждено на стадии архитектуры: `git merge-base --is-ancestor origin/main HEAD`
|
||||
→ true (origin/main содержит merge-коммит ORCH-086, #86); в `src/reconciler.py`
|
||||
ветки присутствуют 43 маркера ORCH-086 (`skipped_terminal_total`, `state_uuid`,
|
||||
terminal-skip). **Файлы ORCH-087 (`notifications.py`, `db.py`, `agents/launcher.py`,
|
||||
`usage.py`, тесты) НЕ пересекаются с `reconciler.py`** → правки 86 не эродируются.
|
||||
`CHANGELOG.md` правится под `.gitattributes merge=union`. Явная проверка на
|
||||
merge-gate — AC-6.1/AC-6.2 (TC-19).
|
||||
|
||||
---
|
||||
|
||||
## Инварианты (не нарушаются)
|
||||
|
||||
- never-raise по всему пути нотификаций; карточка всегда silent (`disable_notification`).
|
||||
- «одна карточка на задачу»; ≤1 `send` за вызов `update_task_tracker` (R-4).
|
||||
- Ссылки ORCH-067 (`plane_issue_link`), `disable_web_page_preview` ORCH-080 — сохранены.
|
||||
- `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / стадии конвейера — **без изменений**.
|
||||
- Миграции БД аддитивны и идемпотентны (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`),
|
||||
restart-safe на общей прод-БД; данные enduro-trails не трогаются.
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
- **Вариант B (переход дефолта на `edit`)** — устраняет сирот by design, но
|
||||
регрессирует фича-просьбу «карточка внизу» (ORCH-042/067). Лечит симптом, а не
|
||||
причину. Отклонён; `edit` остаётся опцией через kill-switch.
|
||||
- **Вариант A2 (JSON-массив `tasks.tracker_message_ids`)** — компактнее, но
|
||||
read-modify-write блоба сам подвержен lost-update при гонке (d) (два процесса
|
||||
перезапишут JSON друг друга — ровно тот класс багов, что чиним). Строка-на-mid в
|
||||
таблице с раздельными INSERT/UPDATE этого избегает и даёт `deleted_at` для ретрая
|
||||
transient-delete + наблюдаемость. Отклонён в пользу A1.
|
||||
- **Файловый/транзакционный лок против гонки (d)** — избыточен для best-effort
|
||||
silent-карточки; леджер самозалечивается за один переход. Отклонён.
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- Уходит класс багов «замёрзшая сирота» — в чате ровно одна достоверная карточка.
|
||||
- Сохранена фича «карточка всегда внизу» (bump-дефолт).
|
||||
- Эффорт виден рядом с моделью; источник стампа надёжен (момент запуска).
|
||||
- Итоговое время честно и подписано; «магическое» раздутое число устранено.
|
||||
- Все изменения аддитивны/идемпотентны, kill-switch'и сохранены, машина стадий не тронута.
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- **Telegram-лимит 48ч:** сообщения старше 48ч удалить нельзя (`_DELETE_GONE_MARKERS`
|
||||
классифицирует это как «gone» → исключаются из учёта). Совсем старые сироты (до
|
||||
деплоя фикса) могут остаться навсегда — **known limitation** (AC-1.4).
|
||||
- **Остаточная гонка (d):** одна лишняя карточка может прожить один переход до
|
||||
самозалечивания на следующем bump (см. Р-2).
|
||||
- Новая таблица + колонка + один config-флаг — небольшой прирост схемы (оправдан).
|
||||
- Порог `tracker_brd_review_cap_s` — эвристика: легитимный человеческий review длиннее
|
||||
2ч будет отображён как `~2ч`. Порог конфигурируем; компромисс «честность vs точность»
|
||||
в пользу неинтродуцирования аномального застоя в «твоё время».
|
||||
86
docs/work-items/ORCH-087/08-data-requirements.md
Normal file
86
docs/work-items/ORCH-087/08-data-requirements.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Требования к схеме БД — ORCH-087
|
||||
|
||||
Все изменения — **строго аддитивные и идемпотентные** (`CREATE TABLE IF NOT EXISTS`
|
||||
/ `_ensure_column`), restart-safe на живой ОБЩЕЙ прод-БД (SQLite). Данные
|
||||
enduro-trails не трогаются. Существующие колонки/таблицы не ломаются. Точка врезки —
|
||||
`src/db.py::init_db` (рядом с прочими `_ensure_column`/`executescript`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Колонка `agent_runs.effort` (BR-EFF, обязательно)
|
||||
|
||||
```python
|
||||
_ensure_column(conn, "agent_runs", "effort", "TEXT")
|
||||
```
|
||||
|
||||
- Тип `TEXT`, nullable. Хранит РЕАЛЬНО ушедшее в `--effort` значение
|
||||
(`low|medium|high|xhigh|max`) или `NULL`, если флаг не подавался (резолв вернул "").
|
||||
- Заполняется в `launcher._spawn` сразу после `resolve_agent_effort(agent,
|
||||
project_id)` через `UPDATE agent_runs SET effort=? WHERE id=run_id`
|
||||
(`effort or None`).
|
||||
- Читается в `render_task_tracker` (добавить `effort` в SELECT `agent_runs`).
|
||||
- Исторические строки (до миграции) → `effort IS NULL` → суффикс эффорта в карточке
|
||||
опускается; допустим fallback на `resolve_agent_effort(run["agent"])`.
|
||||
- Идемпотентность: `_ensure_column` — no-op при уже существующей колонке (AC-E.1,
|
||||
TC-09).
|
||||
|
||||
## 2. Таблица-леджер `tracker_messages` (BR-G1, вариант A1 ADR-001)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tracker_messages (
|
||||
task_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT, -- NULL = карточка ещё жива (незакрыта)
|
||||
PRIMARY KEY (task_id, message_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_messages_open
|
||||
ON tracker_messages(task_id) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
- Авторитетный учёт ВСЕХ созданных карточек задачи; `deleted_at IS NULL` ⇔ карточка
|
||||
считается живой и подлежит зачистке на следующем bump.
|
||||
- Логический FK на `tasks.id` без `REFERENCES` (зеркалит `jobs.task_id`/`job_deps`) —
|
||||
миграция не падает на pre-existing БД.
|
||||
- Частичный индекс `WHERE deleted_at IS NULL` — дешёвая выборка незакрытых mid в
|
||||
горячем пути рендера/зачистки.
|
||||
- `PRIMARY KEY (task_id, message_id)` — идемпотентность INSERT (повторный mid не
|
||||
дублируется); защита от двойного учёта при гонке.
|
||||
|
||||
**Новые геттеры/сеттеры в `src/db.py` (предложение, точная сигнатура — за разработчиком):**
|
||||
|
||||
| Функция | Назначение |
|
||||
|---------|-----------|
|
||||
| `add_tracker_message(task_id, message_id)` | INSERT нового mid (после успешного `send`). `INSERT OR IGNORE` для идемпотентности. |
|
||||
| `get_open_tracker_messages(task_id) -> list[int]` | Все `message_id` с `deleted_at IS NULL`. |
|
||||
| `mark_tracker_message_deleted(task_id, message_id)` | `UPDATE … SET deleted_at=datetime('now')` для успешно удалённых / «already gone». |
|
||||
|
||||
Контракт — как у существующих хелперов БД (never-raise по месту вызова в
|
||||
notifications: ошибка БД не валит конвейер).
|
||||
|
||||
### Сосуществование со скаляром `tasks.tracker_message_id`
|
||||
|
||||
- `tasks.tracker_message_id` **СОХРАНЯЕТСЯ** без изменения семантики — указатель на
|
||||
ТЕКУЩУЮ карточку (читатели `get_tracker_message_id`/`set_tracker_message_id` не
|
||||
трогаются). Обратная совместимость полная.
|
||||
- Леджер `tracker_messages` — НАДмножество: источник истины для зачистки сирот.
|
||||
- Одноразовый бэкфилл скаляра в леджер **не требуется** (старые сироты всё равно за
|
||||
48ч-окном Telegram). Новый поток ведёт леджер с нуля.
|
||||
|
||||
## 3. Что НЕ меняется
|
||||
|
||||
- `tasks` (кроме отсутствия изменений — скаляр сохранён), `jobs`, `events`,
|
||||
`job_deps`, прочие колонки `agent_runs` (`model`, токены, cost, exit_code) — без
|
||||
изменений.
|
||||
- Никаких `DROP`/`ALTER … DROP`/переименований/перетипизаций (SQLite-небезопасно на
|
||||
живой БД).
|
||||
- `STAGE_TRANSITIONS` / `QG_CHECKS` — вне зоны БД, не затрагиваются.
|
||||
|
||||
## 4. Идемпотентность и restart-safety (проверка)
|
||||
|
||||
- Двойной вызов `init_db` → без ошибок (`IF NOT EXISTS` / `_ensure_column` no-op) —
|
||||
TC-09.
|
||||
- Леджер переживает рестарт орка: незакрытые mid читаются из БД → следующий bump
|
||||
подчищает старые карточки (TC-05, AC-1.3).
|
||||
- Миграция на БД с существующими данными enduro: только добавляет колонку/таблицу,
|
||||
данные нетронуты (AC-X.5).
|
||||
29
docs/work-items/ORCH-087/10-tech-risks.md
Normal file
29
docs/work-items/ORCH-087/10-tech-risks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Технические риски — ORCH-087
|
||||
|
||||
Зона изменений: `src/notifications.py`, `src/db.py`, `src/agents/launcher.py`,
|
||||
`src/usage.py`, тесты. Машина стадий и QG не затрагиваются. Контракт компонента —
|
||||
never-raise, карточка silent.
|
||||
|
||||
| ID | Риск | Вероятность / Влияние | Митигация |
|
||||
|----|------|------------------------|-----------|
|
||||
| R-1 | **Self-hosting:** задача правит инструмент в проде, обслуживающем enduro-trails из общей БД/очереди. Регресс пути нотификаций мог бы испортить наблюдаемость всех проектов. | Низк. / Сред. | never-raise сохранён по всему пути; обязательный `deploy-staging` (8501) гейт перед прод-деплоем; нотификации не на критическом пути конвейера (ошибка не валит стадии). |
|
||||
| R-2 | **Telegram 48ч-лимит:** сироты старше 48ч неудаляемы → могут остаться навсегда. | Сред. / Низк. | Документировано как known-limitation (ADR §Последствия, AC-1.4); `_DELETE_GONE_MARKERS` классифицирует как «gone» → исключает из учёта, не зацикливает ретраи. Касается только сирот ДО деплоя фикса. |
|
||||
| R-3 | **Гонка (d)** двух `update_task_tracker` (queue-worker / reconciler / reaper) → лишняя карточка на один переход. | Сред. / Низк. | Леджер самозалечивается на следующем bump (ADR Р-2); строго лучше текущего постоянного сиротства; кросс-процессный лок сознательно не вводится (цена > выгоды для silent-карточки). |
|
||||
| R-4 | **Миграция на живой общей прод-БД** (SQLite). Неаддитивная правка могла бы тронуть данные enduro. | Низк. / Выс. | Только `CREATE TABLE IF NOT EXISTS` / `_ensure_column` (идемпотентно, no-op при существовании); никаких DROP/ALTER DROP/переименований; логический FK без `REFERENCES` (не падает на pre-existing БД). TC-09 проверяет идемпотентность. |
|
||||
| R-5 | **BR-G6 / merge-gate:** ветка должна жить поверх свежего `origin/main` (ORCH-86); эрозия `reconciler.py` затёрла бы terminal-skip/`state_uuid`-dedup. | Низк. / Выс. | Подтверждено: origin/main — предок HEAD; 43 маркера ORCH-086 на месте; файлы ORCH-087 НЕ пересекают `reconciler.py`. `CHANGELOG.md` под `.gitattributes merge=union`. Явная проверка merge-gate — TC-19 (AC-6.1/6.2). |
|
||||
| R-6 | **Порог `tracker_brd_review_cap_s`** (дефолт 2ч): легитимный человеческий BRD-review длиннее 2ч отобразится как `~2ч` (недо-отчёт). | Сред. / Низк. | Конфигурируем (env); компромисс в пользу неинтродуцирования аномального застоя в «твоё время». Маркер `~` сигнализирует отсечку. Главная метрика (агенты) остаётся точной. |
|
||||
| R-7 | **Стамп эффорта в `_spawn`:** доп. `UPDATE agent_runs` сразу после INSERT мог бы упасть и сорвать запуск агента. | Низк. / Сред. | `UPDATE` по существующему `run_id` в уже открытом соединении; в худшем случае effort=NULL → суффикс опускается (рендер не падает, AC-E.4). Эффорт — наблюдаемость, не функциональность запуска. |
|
||||
| R-8 | **Регресс существующих тестов нотификаций** (новый формат строки стадии с эффортом + новая done-строка времени). | Сред. / Низк. | Обновить ожидания в `tests/test_notifications*.py`; новый формат строго аддитивен (суффикс эффорта/подписи времени). TC-11…TC-15. |
|
||||
| R-9 | **Live-overlay `confirm_deploy`:** новый ключ overlay при отсутствии UUID статуса в проекте мог бы шуметь/падать. | Низк. / Низк. | overlay never-raise, деградирует на offline-label при отсутствии UUID/ошибке; kill-switch `tracker_live_status`; без base-alias (реальный отдельный статус). |
|
||||
|
||||
## Острые точки внимания для разработчика
|
||||
|
||||
1. **Порядок в bump:** зачистка ВСЕХ открытых mid из леджера → `send` → INSERT+repoint
|
||||
ТОЛЬКО при `new_mid is not None` (R-3/BR-6). Ровно один `send` за вызов (R-4).
|
||||
2. **never-raise:** любая ошибка БД-леджера / Telegram внутри `update_task_tracker`
|
||||
гасится (как сейчас) — конвейер не падает (TC-16, AC-X.2).
|
||||
3. **Эффорт = фактический флаг:** хранить `resolve_agent_effort(...)` как ушло в
|
||||
`--effort` (пусто → NULL), а не пересчёт постфактум (CLI не возвращает эффорт).
|
||||
4. **Не трогать** `reconciler.py`/`tests/test_reconciler.py` (BR-G6).
|
||||
5. **Сохранить** `plane_issue_link` (ORCH-067) и `disable_web_page_preview` (ORCH-080)
|
||||
в payload (TC-17, AC-X.4).
|
||||
72
docs/work-items/ORCH-087/12-review.md
Normal file
72
docs/work-items/ORCH-087/12-review.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-087
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-087
|
||||
|
||||
## Summary
|
||||
Задача закрывает три проблемы live-трекера: (G1) осиротевшие «замёрзшие» карточки,
|
||||
(BR-EFF) эффорт в строке стадии, (BR-G5) честное итоговое время, плюс попутный CI-фикс
|
||||
пути per-run логов. Реализация соответствует ТЗ, ADR-001 и критериям приёмки. Все 1090
|
||||
тестов зелёные. Документация (CLAUDE.md, README.md, docs/architecture/README.md,
|
||||
CHANGELOG.md, ADR) обновлена в том же PR. Машина стадий и реестр QG не тронуты; миграции
|
||||
аддитивны/идемпотентны; never-raise сохранён. Найдена одна косметика P3 (неточный
|
||||
inline-комментарий), не влияющая на поведение. Блокеров нет.
|
||||
|
||||
## Соответствие ТЗ / ADR
|
||||
|
||||
- **G1 (BR-G1, AC-1.x):** аддитивный леджер `tracker_messages(task_id, message_id,
|
||||
created_at, deleted_at)` + хелперы `add_tracker_message` / `get_open_tracker_messages` /
|
||||
`mark_tracker_message_deleted` (`src/db.py`). На каждом bump зачищаются ВСЕ незакрытые
|
||||
mid (union скаляр+леджер). Контракт `delete_telegram` (True=gone вкл. `_DELETE_GONE_MARKERS`,
|
||||
False=transient) совпадает с логикой `if delete_telegram(old): mark_deleted(...)`;
|
||||
transient остаётся открытым для ретрая. Новый mid в леджер ТОЛЬКО при `send is not None`
|
||||
(R-3/BR-6). Скаляр `tracker_message_id` сохранён (BC). ✔ соответствует ADR §G1 (вариант A1).
|
||||
- **G3 (AC-3.x):** ключ `confirm_deploy` добавлен в `_LIVE_BRANCH_LABELS` — цикл
|
||||
`Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done` полон. ✔
|
||||
- **BR-EFF (AC-E.x):** колонка `agent_runs.effort TEXT` (`_ensure_column`, идемпотентно);
|
||||
стамп фактического `resolve_agent_effort` в `launcher._spawn` через `UPDATE` по `run_id`
|
||||
(never-block, обёрнут try/except); рендер `· {model} · {effort}`, пустой → опускается. ✔
|
||||
- **BR-G5 (AC-5.x):** done-строка переписана на три подписанных метрики
|
||||
`⏱️ Агенты · твоё{~cap} · общее с ожиданием`; кап `tracker_brd_review_cap_s` (дефолт 2ч,
|
||||
маркер `~`); `_capped_review_str` never-raise; agent-сумма не регрессировала. ✔
|
||||
- **BR-G6 (AC-6.x):** `src/reconciler.py` / `tests/test_reconciler.py` НЕ тронуты;
|
||||
`git merge-base --is-ancestor origin/main HEAD` → true; origin/main содержит merge ORCH-086;
|
||||
маркеры ORCH-086 (`skipped_terminal_total`/`state_uuid`/terminal) на месте. ✔
|
||||
- **Инварианты (AC-X.5):** `STAGE_TRANSITIONS` / `QG_CHECKS` без изменений; миграции
|
||||
`CREATE TABLE/INDEX IF NOT EXISTS` + `_ensure_column` — аддитивны/идемпотентны (enduro не
|
||||
трогается); `disable_notification` / `plane_issue_link` / `disable_web_page_preview` —
|
||||
сохранены. ✔
|
||||
- **ADR (AC-0.x):** ADR-001 отвечает на 4 вопроса §4 BRD, содержит таблицу staging-
|
||||
воспроизведения и known-limitation Telegram 48ч (AC-1.4). Код фикса соответствует ADR. ✔
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
### P3 — Nice to have
|
||||
- [ ] `src/notifications.py` (~стр. 460, докстринг блока рендера эффорта): комментарий
|
||||
утверждает «Historical rows with NULL effort fall back to the config-resolved effort for
|
||||
the agent», но `_run_effort` фолбэка на `resolve_agent_effort` НЕ делает — при пустом/NULL
|
||||
effort возвращает `""` и суффикс опускается. Поведение корректно и соответствует AC-E.4
|
||||
(fallback по ТЗ §5 был «Допустим», не обязателен); неточен лишь комментарий — стоит убрать
|
||||
вводящую в заблуждение фразу или реально добавить фолбэк. Не влияет на работу.
|
||||
|
||||
## Документация
|
||||
Обновлена в ТОМ ЖЕ PR (AC-X.3 выполнен):
|
||||
- `CLAUDE.md` — §Нотификации/Telegram live-tracker (зачистка сирот, эффорт, честное время).
|
||||
- `docs/architecture/README.md` — компонент Notifications + отдельный раздел ORCH-087.
|
||||
- `README.md` — таблица env (`ORCH_RUNS_DIR`).
|
||||
- `CHANGELOG.md` — `## [Unreleased]` запись (ORCH-087 трекер + CI-фикс пути логов).
|
||||
- ADR `06-adr/ADR-001-tracker-orphan-cleanup.md` — присутствует, покрывает G0/механизм/формулу.
|
||||
Замечаний по документации нет.
|
||||
88
docs/work-items/ORCH-087/13-test-report.md
Normal file
88
docs/work-items/ORCH-087/13-test-report.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-087
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-087
|
||||
|
||||
Багфикс live-трекера: зачистка осиротевших карточек (G1), эффорт в строке стадии
|
||||
(BR-EFF), честное итоговое время (BR-G5), плюс deploy-цикл на карточке (G3).
|
||||
Review-вердикт `12-review.md` — **APPROVED**. Прогнан полный регресс + smoke API.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: `feature/ORCH-087-orch-87-to-analyse-bump`
|
||||
- Репозиторий: orchestrator (worktree)
|
||||
- Прод-инстанс (8500): health `ok` — деструктивных операций не выполнялось
|
||||
- Дата: 2026-06-09
|
||||
|
||||
## Результаты — тест-план (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест(ы) | Результат |
|
||||
|-------|----------|---------|-----------|
|
||||
| TC-01 | bump удаляет ВСЕ незакрытые message_id, не только последний | `test_notifications_orphans.py` | PASS |
|
||||
| TC-02 | send→None → учёт mid не теряется (BR-6/R-3) | `test_notifications_orphans.py` | PASS |
|
||||
| TC-03 | delete=False (transient) остаётся в учёте; «already gone» исключается | `test_notifications_orphans.py` | PASS |
|
||||
| TC-04 | повторные вызовы → одна живая карточка, ≤1 send, без дублей | `test_notifications_orphans.py` | PASS |
|
||||
| TC-05 | учёт mid переживает «рестарт» (читается из БД) | `test_notifications_orphans.py` | PASS |
|
||||
| TC-06 | plane_status_label детерминирован для created..done; deploy→Awaiting Deploy | `test_tracker_status_line.py` (parametrized) | PASS |
|
||||
| TC-07 | заголовок/статус соответствуют tasks.stage (нет застывшего To Analyse) | `test_tracker_status_line.py` | PASS |
|
||||
| TC-08 | live-overlay рисует Deploying/Monitoring; деградирует на offline при kill-switch | `test_tracker_status_line.py` | PASS |
|
||||
| TC-09 | миграция agent_runs.effort идемпотентна (_ensure_column) | `test_launcher.py` / db-fallback тесты | PASS |
|
||||
| TC-10 | launcher стампит resolve_agent_effort в agent_runs.effort при запуске | `test_launcher.py` (effort, 2 теста) | PASS |
|
||||
| TC-11 | строка стадии рендерит эффорт рядом с моделью; dev=xhigh, tester/deployer=medium, прочие=high | `test_tracker_effort_time.py` | PASS |
|
||||
| TC-12 | пустой/неизвестный effort → суффикс опускается, рендер не падает | `test_tracker_effort_time.py` | PASS |
|
||||
| TC-13 | brd_review ~6ч (застой) → «твоё время» НЕ показывает ~6ч (cap) | `test_tracker_effort_time.py` | PASS |
|
||||
| TC-14 | agent-время = Σ agent_runs точно; 💰-итоги без регресса | `test_tracker_effort_time.py` | PASS |
|
||||
| TC-15 | done-строка: wall помечен «общее (с ожиданием)»; числа согласованы | `test_tracker_effort_time.py` | PASS |
|
||||
| TC-16 | update_task_tracker/render никогда не raise при ошибке Telegram/БД | `test_tracker_status_line.py` / `test_notifications_orphans.py` | PASS |
|
||||
| TC-17 | ссылки ORCH-067 (plane_issue_link) и disable_web_page_preview ORCH-080 сохранены | `test_tracker_issue_link.py` | PASS |
|
||||
| TC-18 | staging-воспроизведение (G0): одна актуальная карточка без сирот | ADR-001 (таблица воспроизведения) | PASS (по ADR) |
|
||||
| TC-19 | merge-gate: ветка поверх origin/main с ORCH-86; reconciler не эродирован; pytest зелёный | `git merge-base` + регресс | PASS |
|
||||
|
||||
## Критерии приёмки (03-acceptance-criteria.md)
|
||||
- **G0 (AC-0.x):** ADR-001 присутствует, отвечает на 4 вопроса §4 BRD, содержит таблицу
|
||||
staging-воспроизведения и known-limitation 48ч → PASS.
|
||||
- **G1 (AC-1.x):** леджер `tracker_messages`, мульти-mid зачистка, send→None защита,
|
||||
unit-покрытие зелёное → PASS.
|
||||
- **G2/G3 (AC-2.x/3.x):** plane_status_label детерминирован для всех стадий; ключ
|
||||
`confirm_deploy` в `_LIVE_BRANCH_LABELS`; deploy→Awaiting Deploy offline → PASS.
|
||||
- **BR-EFF (AC-E.x):** колонка `agent_runs.effort` идемпотентна, стамп в `_spawn`,
|
||||
рендер `· model · effort`, значения по ORCH-41/081 → PASS.
|
||||
- **BR-G5 (AC-5.x):** три подписанных метрики, cap `tracker_brd_review_cap_s`,
|
||||
agent-сумма точна → PASS.
|
||||
- **BR-G6 (AC-6.x):** `git merge-base --is-ancestor origin/main HEAD` → TRUE;
|
||||
`src/reconciler.py` — 35 вхождений маркеров ORCH-086 (`skipped_terminal_total`/
|
||||
`state_uuid`), логика не эродирована → PASS.
|
||||
- **Сквозные (AC-X.x):** одна карточка/≤1 send; полный pytest зелёный (never-raise);
|
||||
доки обновлены; ссылки/preview сохранены; STAGE_TRANSITIONS/QG_CHECKS не тронуты → PASS.
|
||||
|
||||
## Smoke test API (прод 8500, read-only)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}`
|
||||
- `GET /status` → отвечает; active_tasks включает ORCH-087 (stage `testing`)
|
||||
- `GET /queue` → отвечает; `counts.running=1`, reconcile/reaper/post_deploy/merge_verify
|
||||
блоки в норме; `skipped_terminal_total` присутствует (ORCH-086 наблюдаемость жива)
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.12.13, pytest-8.3.3
|
||||
collected 1090 items
|
||||
...
|
||||
======================= 1090 passed, 1 warning in 29.87s =======================
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не связана с задачей.)
|
||||
|
||||
ORCH-087-специфичные модули (повторный прогон):
|
||||
- `test_notifications_orphans.py` — 7 passed
|
||||
- `test_tracker_effort_time.py` — 12 passed
|
||||
- `test_tracker_status_line.py` — 18 passed
|
||||
- `test_tracker_bump.py` + `test_tracker_bump_default.py` — 21 passed
|
||||
- `test_launcher.py -k effort` — 2 passed
|
||||
|
||||
## Итог
|
||||
**PASS** — все 1090 тестов зелёные, smoke API OK, все критерии приёмки выполнены,
|
||||
инварианты (never-raise, одна карточка, STAGE_TRANSITIONS/QG неизменны, BR-G6
|
||||
свежий main без эрозии reconciler) соблюдены. Задача готова к стадии deploy-staging.
|
||||
12
docs/work-items/ORCH-087/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-087/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-087
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
42
docs/work-items/ORCH-087/15-staging-log.md
Normal file
42
docs/work-items/ORCH-087/15-staging-log.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-09T07:04:58Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
|
||||
Run canonically inside the `orchestrator-staging` container (`docker exec`, ORCH-048 / ADR-001),
|
||||
mode `stub`. Exit code **0** → advance.
|
||||
|
||||
## Verdict
|
||||
|
||||
- **Result:** 8/10 checks PASS, exit code 0.
|
||||
- **REAL failed:** none.
|
||||
- **SANDBOX_INFRA failed (waived, ORCH-061):** C9a, C9b.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Check breakdown
|
||||
|
||||
| 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 (waived: SANDBOX_INFRA) |
|
||||
| C | C9b Analyst job enqueued in staging queue | FAIL (waived: SANDBOX_INFRA) |
|
||||
|
||||
The two waived failures are the known sandbox-infra checks (C9a/C9b) that depend on SANDBOX
|
||||
bot accounts being members of the sandbox project — not on the pipeline. All REAL checks are
|
||||
green, so the suite exits 0 (fail-closed for REAL checks is preserved). Cleanup ran: Plane
|
||||
test issue deleted (HTTP 204), no orphan branch.
|
||||
7
docs/work-items/ORCH-088/00-business-request.md
Normal file
7
docs/work-items/ORCH-088/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-88 [ЭПИК]: пакетный автономный режим (10-20 задач за ночь) — последовательно → потом параллельно
|
||||
|
||||
Work Item ID: ORCH-088
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
145
docs/work-items/ORCH-088/01-brd.md
Normal file
145
docs/work-items/ORCH-088/01-brd.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 01 — BRD: ORCH-088 — Пакетный автономный режим (Этап 1: serial e2e)
|
||||
|
||||
Work Item: **ORCH-088**
|
||||
Repo: **orchestrator** (self-hosting)
|
||||
Стадия: analysis
|
||||
Заказчик: Слава
|
||||
Тип: ЭПИК — Этап 1 (минимальный, без параллелизма)
|
||||
|
||||
> ⚠️ **Скоп зафиксирован Владельцем 09.06.** Реализуется ТОЛЬКО serial e2e (FR-1…FR-5).
|
||||
> Фазовый режим A/B/C, merge-очередь FIFO, pre-merge rebase и зависимость от ORCH-83 —
|
||||
> **ОТМЕНЕНЫ, не реализовывать.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-контекст и проблема
|
||||
|
||||
### 1.1. Цель эпика
|
||||
Дать оркестратору **масштаб автономности**: накидать вечером 10–20 задач и получить к утру
|
||||
последовательно проведённый через весь конвейер (analysis → … → deploy → done) пакет — без
|
||||
ручного запуска каждой задачи и без взаимного повреждения веток.
|
||||
|
||||
### 1.2. Корневая проблема — «stale-анализ» (логический, а не код-затирание)
|
||||
Конвейер создаёт ветку задачи от `main`. Если задача **N+1** входит в анализ, пока задача **N**
|
||||
ещё **не влита в `main`**, то ветка N+1 срезается от **устаревшего** `main` (без кода N). Результат:
|
||||
- семантически устаревшая база разработки;
|
||||
- риск потери/переоткрытия уже сделанного в N (накопительные потери прецедента — постмортем
|
||||
фантомного merge, см. CLAUDE.md / ORCH-071);
|
||||
- ручной разбор конфликтов утром вместо готового пакета.
|
||||
|
||||
Физическое **код-затирание** при параллельном merge уже закрыто (ORCH-026 auto_rebase + merge-lease).
|
||||
ORCH-088 закрывает **логический** разрыв: гарантирует, что каждая следующая задача стартует от
|
||||
`main`, **уже содержащего все предыдущие завершённые задачи репо**.
|
||||
|
||||
### 1.3. Почему сериализация именно «от АНАЛИЗА», а не «от merge»
|
||||
Ветка срезается в самом начале — на входе в анализ (`start_pipeline` создаёт ветку в Gitea, далее
|
||||
worktree). Если допустить параллельный анализ N и N+1, ветка N+1 уже срезана от старого `main` —
|
||||
поздняя сериализация на merge проблему не лечит. Поэтому gate ставится на **входе новой задачи в
|
||||
анализ**: новая задача не начинает анализ (и не режет ветку), пока в репо есть незавершённая задача.
|
||||
|
||||
### 1.4. Установленные факты (проверено, не изобретать)
|
||||
- **Plane API v1:** bulk-операций НЕТ; issue-relation НЕТ → зависимости/очередь оркестратор хранит
|
||||
**у себя** (gate в планировщике/claim по локальной БД), не в Plane.
|
||||
- **Уже есть (переиспользовать):** `max_concurrency=1`; ORCH-026 auto_rebase_onto_main +
|
||||
force-with-lease + merge-lease; персистентная очередь ORCH-1 (таблица `jobs`, atomic claim,
|
||||
restart-safe); ORCH-021 post-deploy monitor (для self — всегда `ALERT_ONLY`, db-стадия `done`
|
||||
достигается ДО окна мониторинга — ORCH-071/066).
|
||||
|
||||
### 1.5. Решения Владельца (09.06) — приняты как требования
|
||||
| # | Решение |
|
||||
|---|---------|
|
||||
| D-1 | Serial e2e подтверждён. BRD появляются **по одному** — осознанный размен: надёжность > батч-просмотр BRD. |
|
||||
| D-2 | Сигнал «задача завершена» = **успешный прод-деплой** (`stage = done` после прод-деплоя). НЕ merge, НЕ staging. |
|
||||
| D-3 | Мониторинг (~15 мин) **НЕ ждём**: gate N+1 открывается по `stage = done`, не по завершению окна мониторинга. |
|
||||
| D-4 | Auto-rollback прода во время мониторинга → **заморозить gate + алерт**; следующая НЕ стартует до ручного снятия. |
|
||||
| D-5 | Зависимость ORCH-088 ← ORCH-83 **убрана** — запускается независимо. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Объём (scope)
|
||||
|
||||
### 2.1. В объёме (Этап 1)
|
||||
- **FR-1 — Serial gate (per-repo):** новая задача не входит в `analysis` (не режет ветку, не
|
||||
запускает analyst), пока в том же репо есть незавершённая задача (`stage < done`).
|
||||
- **FR-2 — Очередь e2e:** накиданные задачи становятся в очередь и обрабатываются **строго по одной**
|
||||
end-to-end (от анализа до прод-деплоя).
|
||||
- **FR-3 — Per-repo изоляция:** сериализация действует **внутри одного репо**; разные репо
|
||||
(`orchestrator`, `enduro-trails`) идут **параллельно** (независимые `main`).
|
||||
- **FR-4 — Restart-safe:** активная задача и состояние gate определяются по **БД** (не in-memory) —
|
||||
переживают рестарт оркестратора.
|
||||
- **FR-5 — Rollback-freeze:** auto-rollback / деградация прода → gate репо **заморожен** + Telegram-
|
||||
алерт; следующая задача не стартует до **ручного** снятия заморозки.
|
||||
|
||||
### 2.2. Вне объёма (явно, не делать)
|
||||
- Merge-очередь FIFO; pre-merge rebase как отдельная фича; фазовый режим A/B/C; любая координация
|
||||
**параллелизма** задач внутри одного репо.
|
||||
- Изменение `STAGE_TRANSITIONS`, реестра `QG_CHECKS`, новых стадий конвейера.
|
||||
- Зависимость от ORCH-83.
|
||||
|
||||
---
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
- **Владелец/оператор (Слава):** накидывает пакет вечером, разбирает заморозку при сбое, читает
|
||||
алерты, снимает freeze вручную.
|
||||
- **Self-hosting прод (`orchestrator`):** обслуживает enduro-trails из того же инстанса — нельзя
|
||||
ронять/блокировать конвейер enduro (FR-3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Бизнес-требования (BR)
|
||||
|
||||
| ID | Требование | Связь |
|
||||
|----|------------|-------|
|
||||
| BR-1 | Пока в репо есть задача со `stage < done`, любая **другая** задача того же репо не начинает анализ — ждёт в очереди. | FR-1, AC-1 |
|
||||
| BR-2 | Как только активная задача достигла `stage = done` (после прод-деплоя), следующая задача того же репо **автоматически** стартует анализ. | FR-1/FR-2, AC-2, D-2 |
|
||||
| BR-3 | Ветка новой задачи срезается от `main`, **уже содержащего все ранее завершённые задачи репо** — нет stale-base. Branch не создаётся раньше, чем предшественник завершён. | FR-1, AC-6, §1.2 |
|
||||
| BR-4 | Сериализация — строго per-repo; задачи разных репо идут параллельно, gate одного репо не влияет на другой. | FR-3, AC-4 |
|
||||
| BR-5 | Активная задача и факт заморозки определяются из БД; после рестарта оркестратора gate ведёт себя идентично (не «забывает» активную задачу и не «теряет» freeze). | FR-4, AC-3 |
|
||||
| BR-6 | Auto-rollback/деградация прода (post-deploy) → per-repo freeze + Telegram-алерт; следующая задача не стартует до ручного снятия freeze. | FR-5, AC-5, D-4 |
|
||||
| BR-7 | Мониторинг прода (~15 мин) gate **не ждёт** — открытие gate привязано к `stage = done`. (Freeze BR-6 — отдельный, независимый от `stage` сигнал, т.к. к моменту деградации задача уже `done`.) | D-3, AC-5 |
|
||||
| BR-8 | Поведение управляется kill-switch'ом и областью репо (как ORCH-35/43/58): выключение флага → строго прежнее поведение (нулевая регрессия для enduro). | NFR |
|
||||
| BR-9 | Состояние gate наблюдаемо в `GET /queue` (активная задача репо, очередь ожидающих, статус freeze). | NFR |
|
||||
|
||||
---
|
||||
|
||||
## 5. Нефункциональные требования (NFR)
|
||||
|
||||
| ID | Требование |
|
||||
|----|------------|
|
||||
| NFR-1 | **never-raise:** любая ошибка логики gate не роняет claim/конвейер. Поведение при ошибке БД — **fail-open** для claim (транзиентный сбой не должен заклинить очередь ВСЕХ проектов), **fail-closed** для freeze (сомнение в безопасности прода → не стартовать). |
|
||||
| NFR-2 | **Offline-устойчивость:** проверка gate в горячем цикле claim не должна ходить в сеть (Plane/Gitea) — иначе встанет очередь всех проектов. Источник истины — локальная БД. |
|
||||
| NFR-3 | **Restart-safe:** никакого in-memory состояния; freeze и активная задача — в БД. |
|
||||
| NFR-4 | **Нулевая регрессия:** при выключенном флаге запрос claim и путь старта идентичны текущим; enduro не затрагивается. |
|
||||
| NFR-5 | **Инварианты неизменны:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды deploy-хука, merge-gate, схема post-deploy — не меняются (допустима только аддитивная, идемпотентная миграция БД). |
|
||||
| NFR-6 | **Self-hosting безопасность:** механизм не рестартит/не роняет прод-контейнер; freeze — пассивная остановка стартов, не действие над прод. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
- `max_concurrency = 1` остаётся (Этап 1 без параллелизма); gate не зависит от значения, но не
|
||||
ослабляет его.
|
||||
- «Завершена» = `tasks.stage = 'done'`. Для self-hosting `done` достигается merge-verify + прод-деплой
|
||||
(ORCH-071/036); пост-деплойное окно мониторинга идёт **после** `done` и gate его не ждёт (BR-7).
|
||||
- Задача в статусе **Blocked / Needs Input** имеет `stage < done` и, следовательно, **держит gate
|
||||
закрытым** — это сознательное поведение (Этап 1): пока задача не доведена до прод или не закрыта
|
||||
оператором, пакет не движется. (Поведение зафиксировать в AC; альтернатива — вне скопа.)
|
||||
- Снятие freeze (BR-6) — **ручное** (оператор), автоматического разбора деградации нет.
|
||||
|
||||
---
|
||||
|
||||
## 7. Критерии успеха (резюме; детали — 03-acceptance-criteria.md)
|
||||
- AC-1 активная задача (`stage<done`) → новая не стартует анализ.
|
||||
- AC-2 активная достигла `done` → следующая стартует автоматически.
|
||||
- AC-3 gate переживает рестарт (состояние в БД).
|
||||
- AC-4 разные репо идут параллельно.
|
||||
- AC-5 auto-rollback → freeze + алерт, следующая не стартует до ручного снятия.
|
||||
- AC-6 каждая ветка срезана от `main` со всеми предыдущими завершёнными задачами репо (нет stale-base).
|
||||
|
||||
---
|
||||
|
||||
## 8. Риски (детали — 10-tech-risks.md, заполняет архитектор)
|
||||
- R-1: stale-base сохраняется, если ветка режется на входе (`_create_gitea_branch` в `start_pipeline`)
|
||||
до завершения предшественника — gate обязан отсрочить **создание ветки**, а не только claim.
|
||||
- R-2: gate, ошибочно fail-closed на транзиентной ошибке БД, заклинит очередь всех проектов.
|
||||
- R-3: «вечный freeze» / залипшая активная задача в Blocked останавливает пакет — нужна наблюдаемость
|
||||
и ручное снятие.
|
||||
210
docs/work-items/ORCH-088/02-trz.md
Normal file
210
docs/work-items/ORCH-088/02-trz.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 02 — ТЗ (TRZ): ORCH-088 — Serial gate (Этап 1: пакетный автономный режим, serial e2e)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
> Документ описывает **что** должно измениться и **где** (модули/контракты/артефакты). **Как**
|
||||
> (конкретная схема реализации, выбор «таблица vs sentinel», точки врезки) — решает архитектор в
|
||||
> `06-adr/`. ТЗ фиксирует требования и границы, не предлагает архитектурное решение.
|
||||
|
||||
> ⚠️ Скоп — только FR-1…FR-5 (serial e2e). Merge-очередь / pre-merge rebase / фазы A/B/C / ORCH-83 —
|
||||
> вне скопа.
|
||||
|
||||
---
|
||||
|
||||
## 1. Сводка изменения
|
||||
Ввести **per-repo serial gate**: новая задача репо не входит в стадию `analysis` (не режет ветку, не
|
||||
запускает analyst-агент), пока в том же репо есть незавершённая задача (`stage != 'done'`). Открытие
|
||||
gate — по достижении предшественником `stage = 'done'` (после прод-деплоя). Дополнительно — **per-repo
|
||||
freeze** при деградации/rollback прода (post-deploy), снимаемый вручную. Всё — аддитивно, под
|
||||
kill-switch, с областью репо, never-raise, restart-safe. Машина стадий и реестр QG **не меняются**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче | Характер изменения |
|
||||
|--------|---------------|--------------------|
|
||||
| `src/db.py` | `claim_next_job` (горячий claim), схема `tasks`/`jobs`, helper'ы выборки активной задачи репо; (возможно) аддитивная таблица/колонка для freeze | gate-условие в claim + новые read-only helper'ы + аддитивная миграция (идемпотентная, `_ensure_column`/`CREATE TABLE IF NOT EXISTS`) |
|
||||
| `src/queue_worker.py` | вызывает `claim_next_job` в `_drain_once` | без изменения контракта; gate работает внутри claim |
|
||||
| `src/webhooks/plane.py` | `start_pipeline` / `handle_status_start` / `_create_gitea_branch` | **отсрочка создания ветки** до момента, когда репо свободен (ключевое для AC-6); постановка задачи в очередь ожидания вместо немедленного среза ветки |
|
||||
| `src/git_worktree.py` | `ensure_worktree` — срез ветки от `origin/main` | гарантия: для новой задачи база = свежий `origin/main` после `git fetch` (см. §6) |
|
||||
| `src/agents/launcher.py` | `_spawn` — ленивое создание worktree на claim | согласование с отсрочкой среза ветки (не материализовать stale-ветку) |
|
||||
| `src/stage_engine.py` | `run_post_deploy_monitor` / блок `next_stage == "done"` | при вердикте деградации/rollback — выставить per-repo freeze (FR-5) |
|
||||
| `src/post_deploy.py` | `decide_action` / реакция | сигнал для freeze (`ALERT_ONLY` self / `ROLLBACK*` non-self) → выставление freeze |
|
||||
| `src/config.py` | флаги фичи | новые: `serial_gate_enabled`, `serial_gate_repos` (CSV), при необходимости — флаги freeze |
|
||||
| `src/main.py` | `GET /queue` | новый read-only блок наблюдаемости `serial_gate` |
|
||||
| `src/notifications.py` / `src/plane_sync.py` | алерты freeze | переиспользовать `send_telegram` / `set_issue_blocked` / `notify_*` (never-raise) |
|
||||
|
||||
> Чистую логику gate/freeze желательно вынести в **leaf-модуль** (например `src/serial_gate.py`,
|
||||
> never-raise, по образцу `src/task_deps.py` / `src/post_deploy.py`) — окончательно решает архитектор.
|
||||
|
||||
---
|
||||
|
||||
## 3. Функциональные изменения (требования к поведению)
|
||||
|
||||
### 3.1. FR-1 — Serial gate на входе в анализ
|
||||
- **Условие закрытия gate (per-repo):** для репо `R` gate **закрыт**, если существует задача `A` репо
|
||||
`R` со `stage != 'done'` (любая стадия `created…deploy`), **отличная** от рассматриваемой новой
|
||||
задачи `B`.
|
||||
- **Что блокируется при закрытом gate:** запуск analyst-агента новой задачи `B` **и** создание её
|
||||
ветки (Gitea-ветка + worktree). Branch у `B` не должен быть срезан, пока gate закрыт (иначе stale-base,
|
||||
AC-6).
|
||||
- **Где гейтить:** в горячем пути выбора работы — `db.claim_next_job` (по образцу `task_deps` NOT EXISTS
|
||||
gate), читая ТОЛЬКО локальную БД (NFR-2). Дополнительно — на входе `start_pipeline`, чтобы **не резать
|
||||
ветку** до открытия gate (см. §3.3).
|
||||
- **Применимость:** gate работает только для analyst-job новой задачи (вход в анализ). Job'ы уже
|
||||
активной задачи (architect/developer/…/deployer) проходят свободно — иначе единственная активная
|
||||
задача не сможет двигаться по конвейеру.
|
||||
|
||||
### 3.2. FR-2 — Очередь e2e
|
||||
- Накиданные задачи репо встают в очередь; обрабатывается строго одна end-to-end. Реализуется
|
||||
естественно: gate держит остальных, активная идёт по стадиям до `done`, затем gate открывается и
|
||||
выбирается следующая (FIFO по существующему порядку очереди `jobs.id`).
|
||||
|
||||
### 3.3. FR-1/AC-6 — Отсрочка среза ветки (анти-stale-base)
|
||||
- **Проблема (проверено):** ветка создаётся в Gitea в `start_pipeline._create_gitea_branch` от `main`
|
||||
в момент перевода issue в «To Analyse» (T0) — **до** того, как предшественник влит. `ensure_worktree`
|
||||
затем **присоединяет уже существующую** Gitea-ветку (а не режет свежую от `origin/main`), т.е. свежий
|
||||
`git fetch` не спасает — база остаётся stale.
|
||||
- **Требование:** создание ветки (Gitea-ветка и/или worktree) для новой задачи должно происходить
|
||||
**после** того, как gate открылся (предшественник `done`), чтобы базой был `origin/main`, уже
|
||||
содержащий код предшественника. Конкретный механизм отсрочки (отложить `_create_gitea_branch`;
|
||||
материализовать ветку лениво при claim'е analyst-job из свежего `origin/main`; и т.п.) — выбирает
|
||||
архитектор. Инвариант результата: **ветка `B` имеет в предках merge-commit/код всех ранее
|
||||
завершённых задач репо** (проверяемо `git merge-base --is-ancestor`).
|
||||
- Если архитектура решит резать ветку при claim'е analyst-job (а не в `start_pipeline`), это
|
||||
автоматически даёт AC-6 (claim происходит только при открытом gate).
|
||||
|
||||
### 3.4. FR-3 — Per-repo
|
||||
- Все выборки gate фильтруются по `tasks.repo` (и `jobs.repo`). Состояние gate/freeze репо `R` не
|
||||
влияет на claim/старт задач другого репо. Cross-repo параллелизм сохранён.
|
||||
|
||||
### 3.5. FR-4 — Restart-safe
|
||||
- «Активная задача репо» вычисляется запросом к БД (`tasks` по `repo` + `stage != 'done'`), не из
|
||||
in-memory. Freeze хранится в БД (аддитивная таблица/колонка). После рестарта поведение идентично.
|
||||
|
||||
### 3.6. FR-5 — Rollback-freeze
|
||||
- При вердикте post-deploy `DEGRADED` (для self — реакция `ALERT_ONLY`; для non-self с
|
||||
`post_deploy_auto_rollback` — `ROLLBACK`) для репо выставляется **durable freeze** (в БД).
|
||||
- При активном freeze репо gate **закрыт безусловно**, независимо от наличия задач `stage<done`
|
||||
(важно: деградировавшая задача к этому моменту уже `stage='done'` — BR-7 — поэтому обычный gate её
|
||||
не удержит; нужен отдельный сигнал).
|
||||
- Снятие freeze — **ручное** (оператор). Способ снятия (эндпоинт/админ-команда/ручная правка БД/
|
||||
Plane-жест) определяет архитектор; требование — снятие должно быть простым, явным и наблюдаемым.
|
||||
- Алерт: Telegram (`send_telegram`/`notify_*`) + Plane `Blocked` для деградировавшей задачи (как
|
||||
ORCH-021), плюс явное сообщение «пакет заморожен, следующая задача не стартует до ручного снятия».
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
### 4.1. Новые публичные endpoint'ы
|
||||
- **Нет обязательных новых endpoint'ов.** (Снятие freeze может быть реализовано как админ-эндпоинт —
|
||||
на усмотрение архитектора; если вводится, описать в ADR и обновить таблицу API в README.)
|
||||
|
||||
### 4.2. Изменяемые endpoint'ы
|
||||
- `GET /queue` — **аддитивно** добавляется блок `serial_gate` (read-only снимок), по образцу блоков
|
||||
`task_deps` / `reconcile` / `post_deploy`:
|
||||
- `enabled` (флаг), `repos` (область),
|
||||
- per-repo: `active_task` (`{work_item_id, stage}` или `null`), `waiting` (список ожидающих
|
||||
задач/job'ов репо), `frozen` (bool) + причина/таймстамп freeze.
|
||||
- never-raise: при ошибке — минимальный словарь с флагами и пустыми данными.
|
||||
- Контракт `GET /queue` — **расширяется аддитивно**, существующие ключи не меняются.
|
||||
|
||||
### 4.3. Webhook-обработчики
|
||||
- `start_pipeline` / `handle_status_start` (`webhooks/plane.py`): добавляется ветвление «репо занят/
|
||||
заморожен → отложить старт/срез ветки, поставить в очередь ожидания» вместо немедленного
|
||||
`_create_gitea_branch` + enqueue. Внешний контракт вебхука Plane не меняется.
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
> Только **аддитивные, идемпотентные** миграции (общая прод-БД, enduro не трогать). Без изменения
|
||||
> существующих таблиц-контрактов.
|
||||
|
||||
- **Freeze-состояние (FR-5):** требуется durable per-repo признак заморозки. Варианты (выбор —
|
||||
архитектор): новая таблица `repo_freeze(repo TEXT, frozen_at TEXT, reason TEXT, work_item_id TEXT,
|
||||
cleared_at TEXT)` **или** аддитивная колонка в существующей таблице. Требования к выбранному варианту:
|
||||
идемпотентная миграция (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), restart-safe, per-repo.
|
||||
- **Активная задача репо:** **новых колонок НЕ требуется** — вычисляется из существующих
|
||||
`tasks(repo, stage)`.
|
||||
- **Очередь ожидания:** переиспользовать существующую `jobs` (status='queued' + gate в claim) — новой
|
||||
таблицы очереди **не вводить** (FR-2 решается gate'ом, не отдельной структурой).
|
||||
- `STAGE_TRANSITIONS`, `QG_CHECKS`, `tasks`-контракт, `job_deps`, `agent_runs` — **без изменений**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Требования к срезу ветки (`git_worktree` / launcher)
|
||||
- Для новой задачи, чья ветка создаётся после открытия gate: перед срезом — `git fetch origin`
|
||||
(уже есть в `ensure_worktree`), база — `origin/main` HEAD.
|
||||
- Гарантировать, что ветка НЕ присоединяется к stale Gitea-ветке, созданной раньше времени: либо не
|
||||
создавать Gitea-ветку преждевременно (отсрочка §3.3), либо при материализации worktree база
|
||||
безусловно = свежий `origin/main` (включающий предшественника).
|
||||
- Никогда не push/force-push в `main`. Существующие merge-lease / auto_rebase (ORCH-026/043) не
|
||||
трогаются.
|
||||
|
||||
---
|
||||
|
||||
## 7. Требования к новым QG checks
|
||||
- **Новых QG-проверок не вводить.** Gate — это условие планировщика (claim / старт), а **не**
|
||||
Quality Gate стадии. Реестр `QG_CHECKS` и `check_*` не меняются (как `task_deps` ORCH-026 —
|
||||
gate в claim, не новый QG).
|
||||
|
||||
## 8. Конфигурация (`src/config.py`)
|
||||
По образцу `task_deps_enabled` / `merge_gate_*` / `post_deploy_*`:
|
||||
- `serial_gate_enabled: bool = True` (env `ORCH_SERIAL_GATE_ENABLED`) — kill-switch; `False` → claim и
|
||||
старт ведут себя строго как сейчас (нулевая регрессия, NFR-4).
|
||||
- `serial_gate_repos: str = ""` (env `ORCH_SERIAL_GATE_REPOS`, CSV) — область; пусто → применять как
|
||||
по умолчанию (см. ниже).
|
||||
- Helper `serial_gate_applies(repo) -> bool` (leaf-модуль, never-raise) по образцу `post_deploy_applies`:
|
||||
`enabled` + (если CSV непуст — членство репо; иначе — область по умолчанию).
|
||||
- **Область по умолчанию (решение для ADR):** serial gate осмыслен для ВСЕХ репо (FR-3 — и orchestrator,
|
||||
и enduro выигрывают от serial e2e), в отличие от self-hosting-only гейтов (ORCH-35/43/58). Рекомендация:
|
||||
пустой CSV → применять ко всем зарегистрированным репо. Архитектор фиксирует и обосновывает в ADR.
|
||||
- При необходимости — отдельные флаги для freeze (FR-5), например `serial_gate_freeze_enabled`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Наблюдаемость и алерты
|
||||
- `GET /queue` блок `serial_gate` (см. §4.2).
|
||||
- Лог: каждое решение «gate закрыт, задача отложена» и «freeze выставлен/снят» → `logger.info/warning`.
|
||||
- Telegram: freeze (выставление) → алерт (`send_telegram`/`notify_*`); карточка задачи (ORCH-042/087)
|
||||
может отражать «⏳ ждёт завершения <work_item_id>» (по образцу строки `task_deps` «⏳ ждёт ORCH-NNN»),
|
||||
never-raise.
|
||||
|
||||
---
|
||||
|
||||
## 10. Артефакты pipeline (создать/обновить в ТОМ ЖЕ PR)
|
||||
Документация — golden source (CLAUDE.md §2). По итогам разработки обновить:
|
||||
- `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md` — решение (механизм отсрочки ветки, freeze-
|
||||
хранилище, область по умолчанию, точки врезки).
|
||||
- `docs/architecture/README.md` — новый раздел «Serial gate (ORCH-088)» + строка статуса доработок;
|
||||
обновить описание `GET /queue` (блок `serial_gate`) и раздел «База данных», если добавлена таблица.
|
||||
- `CLAUDE.md` — краткий абзац о serial-режиме (если уместно в паспорте).
|
||||
- `CHANGELOG.md` — запись `feat:`.
|
||||
- При новой таблице freeze — `docs/work-items/ORCH-088/08-data-requirements.md`.
|
||||
- При новом админ-эндпоинте снятия freeze — обновить таблицу API в README.
|
||||
|
||||
---
|
||||
|
||||
## 11. Инварианты (не нарушать)
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды deploy-хука, merge-gate (ORCH-043),
|
||||
merge-verify (ORCH-071/073), image-freshness (ORCH-058), post-deploy контракт (ORCH-021),
|
||||
`max_concurrency` — **без изменений**.
|
||||
- never-raise на единицу работы; claim fail-**open** на ошибке БД (NFR-1); freeze fail-**closed**.
|
||||
- Offline в горячем claim (NFR-2): без сетевых вызовов Plane/Gitea.
|
||||
- Не рестартить/не ронять прод-контейнер (CLAUDE.md self-hosting).
|
||||
- Миграции аддитивны и идемпотентны; enduro при выключенном/неприменимом флаге не затрагивается.
|
||||
|
||||
---
|
||||
|
||||
## 12. Открытые вопросы для архитектора (не блокируют анализ)
|
||||
- OQ-1: Механизм отсрочки среза ветки — отложить `_create_gitea_branch` в `start_pipeline` ИЛИ
|
||||
перенести материализацию ветки на claim analyst-job? (Влияет на AC-6 и на то, где живёт «ожидающая»
|
||||
задача — в Plane-статусе vs как `queued` job без ветки.)
|
||||
- OQ-2: Хранилище freeze — отдельная таблица `repo_freeze` vs колонка.
|
||||
- OQ-3: Способ ручного снятия freeze (эндпоинт / Plane-жест / админ-команда).
|
||||
- OQ-4: Поведение при задаче в Blocked/Needs-Input, держащей gate закрытым (Этап 1 — держит; нужен ли
|
||||
отдельный «вывод из учёта активных» — вероятно нет, фиксируем как осознанное).
|
||||
- OQ-5: Область по умолчанию (все репо vs только self-hosting) — рекомендация §8.
|
||||
103
docs/work-items/ORCH-088/03-acceptance-criteria.md
Normal file
103
docs/work-items/ORCH-088/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-088 — Serial gate
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
Формат: каждый критерий — чёткое условие **PASS/FAIL**. Критерий считается выполненным, если
|
||||
описанная проверка даёт указанный результат. Нумерация AC-1…AC-6 соответствует BR; AC-7…AC-11 —
|
||||
производные/защитные.
|
||||
|
||||
> Скоп — FR-1…FR-5 (serial e2e). Merge-очередь / pre-merge rebase / фазы A/B/C — вне приёмки.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Gate закрыт при активной задаче
|
||||
**Условие:** в репо `R` есть задача `A` со `stage != 'done'`. В очередь поступает новая задача `B`
|
||||
того же репо.
|
||||
- **PASS:** analyst-агент задачи `B` НЕ запускается; ветка `B` НЕ создаётся; `B` остаётся в ожидании
|
||||
(`jobs.status='queued'` / не стартована). `GET /queue` показывает `B` как ожидающую.
|
||||
- **FAIL:** analyst `B` стартовал, или ветка `B` создана, пока `A` не `done`.
|
||||
|
||||
## AC-2 — Автостарт следующей по достижении `done`
|
||||
**Условие:** активная задача `A` репо `R` достигла `stage = 'done'` (после прод-деплоя). В очереди
|
||||
ждёт `B`.
|
||||
- **PASS:** `B` стартует анализ **автоматически** (без ручного действия) — claim analyst-job `B`
|
||||
происходит на ближайшем цикле планировщика; ветка `B` создаётся в этот момент.
|
||||
- **FAIL:** `B` не стартует после `A.stage='done'`, либо для старта требуется ручное вмешательство.
|
||||
|
||||
## AC-3 — Restart-safe (состояние в БД)
|
||||
**Условие:** активна `A` (`stage<done`), `B` ждёт; оркестратор перезапускается.
|
||||
- **PASS:** после рестарта gate по-прежнему закрыт (`B` не стартовала, `A` определяется из БД);
|
||||
после `A.stage='done'` `B` стартует. Freeze (если был выставлен) сохраняется после рестарта.
|
||||
- **FAIL:** после рестарта `B` стартовала при `A.stage<done`, или freeze «потерян».
|
||||
|
||||
## AC-4 — Per-repo параллелизм
|
||||
**Условие:** активна задача в `orchestrator` (`stage<done`); в `enduro-trails` поступает новая задача.
|
||||
- **PASS:** задача `enduro-trails` стартует анализ независимо (gate orchestrator её не держит) и
|
||||
наоборот; gate/freeze одного репо не влияет на другой.
|
||||
- **FAIL:** задача другого репо заблокирована состоянием gate/freeze чужого репо.
|
||||
|
||||
## AC-5 — Rollback-freeze + алерт
|
||||
**Условие:** задача `A` репо `R` достигла `done`; во время post-deploy мониторинга вынесен вердикт
|
||||
`DEGRADED` (self → `ALERT_ONLY`; non-self+auto_rollback → `ROLLBACK`).
|
||||
- **PASS:** для `R` выставлен durable freeze (в БД); отправлен Telegram-алерт о заморозке; следующая
|
||||
задача репо НЕ стартует, пока freeze не снят **вручную**; `GET /queue` показывает `frozen: true`.
|
||||
После ручного снятия freeze следующая задача стартует.
|
||||
- **FAIL:** следующая задача стартовала при активном freeze; либо freeze снялся автоматически; либо
|
||||
алерт не отправлен.
|
||||
|
||||
## AC-6 — Нет stale-base (ветка от свежего `main`)
|
||||
**Условие:** задачи `A` затем `B` одного репо проходят serial. `A` влита в `main` к моменту своего
|
||||
`done`.
|
||||
- **PASS:** ветка `B` срезана от `main`, **содержащего код `A`**: проверка
|
||||
`git merge-base --is-ancestor <validated_sha задачи A> <branch B>` (или равноценная: HEAD `A` в
|
||||
`main` — предок базы `B`) истинна. Branch `B` не создан раньше `A.stage='done'`.
|
||||
- **FAIL:** база `B` не содержит коммитов `A` (ветка срезана до завершения `A`).
|
||||
|
||||
## AC-7 — Kill-switch / нулевая регрессия
|
||||
**Условие:** `serial_gate_enabled = False` (или репо вне `serial_gate_repos`).
|
||||
- **PASS:** claim и старт ведут себя строго как до ORCH-088 (gate инертен); тесты прежнего поведения
|
||||
зелёные; enduro не затронут.
|
||||
- **FAIL:** при выключенном флаге поведение отличается от исходного.
|
||||
|
||||
## AC-8 — never-raise / fail-open для claim
|
||||
**Условие:** при вычислении gate происходит ошибка БД/логики в горячем пути claim.
|
||||
- **PASS:** ошибка перехвачена и залогирована; claim НЕ падает; для claim — поведение fail-open
|
||||
(очередь всех проектов не заклинивает). Конвейер enduro продолжает работать.
|
||||
- **FAIL:** ошибка gate роняет claim/воркер или заклинивает очередь.
|
||||
|
||||
## AC-9 — fail-closed для freeze
|
||||
**Условие:** при определении состояния freeze возникает сомнение/ошибка (например, не удалось
|
||||
достоверно прочитать признак).
|
||||
- **PASS:** в отношении freeze применяется консервативное (безопасное для прода) поведение — не
|
||||
стартовать следующую при невозможности подтвердить отсутствие freeze (зафиксировать в ADR/коде).
|
||||
- **FAIL:** при сомнении gate открывается и стартует следующую задачу.
|
||||
|
||||
## AC-10 — Наблюдаемость `GET /queue`
|
||||
**Условие:** запрос `GET /queue` при активной задаче и/или freeze.
|
||||
- **PASS:** ответ содержит аддитивный блок `serial_gate` с: `enabled`, областью, per-repo
|
||||
`active_task`, списком `waiting`, `frozen`. Существующие ключи `/queue` не изменены.
|
||||
- **FAIL:** блок отсутствует/ломает существующий контракт, либо данные не отражают реальное состояние.
|
||||
|
||||
## AC-11 — Инварианты неизменны
|
||||
**Условие:** проверка контрактов после внедрения.
|
||||
- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды deploy-хука, merge-gate,
|
||||
merge-verify, image-freshness, post-deploy контракт — без изменений; миграции БД аддитивны и
|
||||
идемпотентны; прод-контейнер не рестартится механизмом gate.
|
||||
- **FAIL:** изменён любой перечисленный контракт, либо миграция не идемпотентна.
|
||||
|
||||
---
|
||||
|
||||
## Сводная матрица AC ↔ FR/BR
|
||||
| AC | FR | BR | Тип проверки |
|
||||
|----|----|----|--------------|
|
||||
| AC-1 | FR-1 | BR-1 | unit (claim/gate) + integration |
|
||||
| AC-2 | FR-1/2 | BR-2 | integration |
|
||||
| AC-3 | FR-4 | BR-5 | integration (restart) |
|
||||
| AC-4 | FR-3 | BR-4 | unit + integration |
|
||||
| AC-5 | FR-5 | BR-6/7 | integration |
|
||||
| AC-6 | FR-1 | BR-3 | integration (git base) |
|
||||
| AC-7 | — | BR-8 | unit |
|
||||
| AC-8 | — | NFR-1 | unit |
|
||||
| AC-9 | FR-5 | NFR-1 | unit |
|
||||
| AC-10 | — | BR-9 | unit (snapshot) |
|
||||
| AC-11 | — | NFR-5 | unit (контракты) |
|
||||
153
docs/work-items/ORCH-088/04-test-plan.yaml
Normal file
153
docs/work-items/ORCH-088/04-test-plan.yaml
Normal file
@@ -0,0 +1,153 @@
|
||||
work_item: ORCH-088
|
||||
title: "Serial gate (Этап 1: пакетный автономный режим, serial e2e)"
|
||||
scope: "FR-1..FR-5 only. Merge-queue / pre-merge rebase / phases A/B/C / ORCH-83 — out of scope."
|
||||
framework: pytest
|
||||
|
||||
# Принципы тестирования:
|
||||
# - чистую логику gate/freeze покрываем unit-тестами на leaf-функциях (без сети/БД где можно);
|
||||
# - claim-gate и e2e-последовательность — integration на временной SQLite-БД;
|
||||
# - все тесты детерминированы (без реальных Plane/Gitea/прод вызовов — мокируются);
|
||||
# - проверяем оба направления kill-switch (вкл/выкл) и never-raise.
|
||||
|
||||
tests:
|
||||
# ---------- FR-1 / AC-1: gate закрыт при активной задаче ----------
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "claim_next_job НЕ выбирает analyst-job новой задачи B, если в репо есть задача A со stage!='done' (gate закрыт)"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "serial_gate_applies(repo): enabled + пустой CSV → True для зарегистрированного репо; CSV с членством → True; репо вне CSV → False"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Job'ы УЖЕ активной задачи (architect/developer/.../deployer) gate'ом НЕ блокируются — единственная активная задача свободно идёт по конвейеру"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-1/2 / AC-2: автостарт следующей по достижении done ----------
|
||||
- id: TC-04
|
||||
type: integration
|
||||
description: "После перевода A.stage='done' claim_next_job выбирает analyst-job ожидающей B того же репо (gate открылся автоматически)"
|
||||
module: tests/test_serial_gate_e2e.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: integration
|
||||
description: "Очередь из 3 задач одного репо обрабатывается строго по одной: пока A не done, ни B, ни C не стартуют; порядок FIFO по jobs.id"
|
||||
module: tests/test_serial_gate_e2e.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-4 / AC-3: restart-safe ----------
|
||||
- id: TC-06
|
||||
type: integration
|
||||
description: "Активная задача определяется из БД (tasks.repo + stage!='done'), не из in-memory — после пересоздания воркера/состояния gate остаётся закрытым при A.stage<done"
|
||||
module: tests/test_serial_gate_e2e.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: "Freeze переживает рестарт: выставленный в БД freeze читается после пересоздания состояния; следующая задача не стартует"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-3 / AC-4: per-repo ----------
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Активная задача в orchestrator (stage<done) НЕ блокирует claim analyst-job задачи в enduro-trails (gate фильтруется по repo)"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Freeze репо orchestrator не влияет на claim/старт задач enduro-trails"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-5 / AC-5: rollback-freeze + алерт ----------
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "post-deploy вердикт DEGRADED → выставляется durable per-repo freeze (запись в БД) + вызывается Telegram-алерт (send_telegram замокан, проверяется вызов)"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "При активном freeze репо claim_next_job НЕ выбирает analyst-job следующей задачи, даже если нет задач stage<done (деградировавшая уже done — BR-7)"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "Ручное снятие freeze → следующая задача стартует на ближайшем цикле; freeze помечается cleared в БД"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- FR-1 / AC-6: нет stale-base ----------
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Ветка B не создаётся (ни Gitea-ветка, ни worktree), пока gate закрыт — _create_gitea_branch/ensure_worktree для B не вызывается при A.stage<done"
|
||||
module: tests/test_serial_gate_branch.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: integration
|
||||
description: "После A.stage='done' (A влита в main) база ветки B = origin/main с кодом A: git merge-base --is-ancestor <sha A> <base B> истинно (на временном git-репо)"
|
||||
module: tests/test_serial_gate_branch.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-7: kill-switch / нулевая регрессия ----------
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "serial_gate_enabled=False → claim_next_job SQL/поведение идентичны исходным (gate инертен); B стартует независимо от A"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "Репо вне serial_gate_repos (CSV непуст) → gate не применяется к этому репо"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-8 / AC-9: never-raise ----------
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "Ошибка БД при вычислении gate в claim → перехвачена, залогирована, claim не падает (fail-OPEN: claim продолжается)"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "Ошибка при определении freeze → fail-CLOSED: следующая не стартует при невозможности подтвердить отсутствие freeze"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-10: наблюдаемость ----------
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: "serial_gate snapshot() возвращает {enabled, repos, per-repo active_task, waiting, frozen}; never-raise при ошибке → минимальный словарь"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-20
|
||||
type: integration
|
||||
description: "GET /queue содержит аддитивный блок serial_gate и НЕ меняет существующие ключи (counts/max_concurrency/reconcile/reaper/post_deploy/task_deps/recent)"
|
||||
module: tests/test_queue_endpoint.py
|
||||
expected: PASS
|
||||
|
||||
# ---------- AC-11: инварианты ----------
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "STAGE_TRANSITIONS и реестр QG_CHECKS не изменены (снимок ключей совпадает с эталоном); новых QG-проверок нет"
|
||||
module: tests/test_serial_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "Миграция freeze-хранилища идемпотентна: повторный вызов init_db/_ensure не падает и не дублирует структуру"
|
||||
module: tests/test_serial_gate_freeze.py
|
||||
expected: PASS
|
||||
221
docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md
Normal file
221
docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# ADR-001: Per-repo serial gate + deferred branch cut + durable rollback-freeze (ORCH-088, Этап 1)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** (self-hosting) · Стадия: architecture
|
||||
Связь: BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, данные `08-data-requirements.md`, риски `10-tech-risks.md`.
|
||||
Сквозная регистрация: `docs/architecture/adr/adr-0017-serial-gate.md`.
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
Эпик ORCH-088 (Этап 1, serial e2e) требует обрабатывать пакет из 10–20 задач **строго по одной**
|
||||
end-to-end и устранить **stale-анализ**: ветка задачи N+1 не должна срезаться от `main`, ещё не
|
||||
содержащего код предшественника N (BRD §1.2). Физическое код-затирание при параллельном merge уже
|
||||
закрыто (ORCH-026 auto_rebase + merge-lease); ORCH-088 закрывает **логический** разрыв.
|
||||
|
||||
Корень проблемы (проверено в коде):
|
||||
1. `webhooks/plane.py::start_pipeline` при переводе issue в анализ:
|
||||
`create_task_atomic(stage='analysis')` → **`_create_gitea_branch(repo, branch)`** (срез Gitea-ветки
|
||||
от `main` в момент T0) → `_create_initial_docs(...)` → `enqueue_job("analyst", ...)`.
|
||||
2. Позже `agents/launcher._spawn` зовёт `git_worktree.ensure_worktree(repo, branch)`, который при
|
||||
**существующей** Gitea-ветке делает `fetch + checkout <branch>` — **присоединяется к stale-ветке**,
|
||||
а не режет свежую. `ensure_worktree` режет от `origin/main` (`git worktree add -b … origin/main`)
|
||||
**только если ветки ещё нет** (git_worktree.py L84-86).
|
||||
|
||||
⇒ Ключ к AC-6: **ветка не должна быть создана раньше, чем предшественник `done`.** Гейтить только
|
||||
claim недостаточно (R-1) — к этому моменту ветка уже срезана.
|
||||
|
||||
Существующий каркас для переиспользования: persistent-очередь ORCH-1 (`jobs`, atomic claim,
|
||||
restart-safe), gate-в-claim ORCH-026 (`task_deps` `NOT EXISTS`), leaf-паттерн `src/task_deps.py` /
|
||||
`src/post_deploy.py` (never-raise), наблюдаемость `GET /queue`, `max_concurrency=1`.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
Вводится **per-repo serial gate** двумя согласованными механизмами, аддитивно, под kill-switch,
|
||||
с областью репо, never-raise, restart-safe. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` — без изменений.
|
||||
|
||||
1. **Gate-в-claim** (`db.claim_next_job`): analyst-job новой задачи **не выбирается**, пока в том же
|
||||
репо есть другая незавершённая задача ИЛИ репо заморожен. Job уже активной задачи
|
||||
(architect/developer/…/deployer) проходят свободно.
|
||||
2. **Отложенный срез ветки**: для применимого репо `start_pipeline` **не** создаёт Gitea-ветку и docs;
|
||||
создание ветки+docs **релоцируется** в момент claim analyst-job (launcher), когда `origin/main`
|
||||
уже содержит предшественника. Это и даёт AC-6 структурно.
|
||||
3. **Durable per-repo freeze** (`repo_freeze`): при post-deploy `DEGRADED` репо замораживается; gate
|
||||
закрыт безусловно до **ручного** снятия.
|
||||
|
||||
Чистая логика — **новый leaf-модуль `src/serial_gate.py`** (never-raise, по образцу
|
||||
`src/task_deps.py` / `src/post_deploy.py`).
|
||||
|
||||
### D1 — Где гейтить вход в анализ: claim + relocation среза ветки (OQ-1)
|
||||
**Решение: релоцировать срез ветки на claim analyst-job, гейтить в `claim_next_job`.**
|
||||
|
||||
- `claim_next_job` получает дополнительный SQL-фрагмент `serial_gate` (строится как существующий
|
||||
`dep_gate`, конкатенацией; только локальная БД — NFR-2 offline). Условие «**не** выбирать job,
|
||||
если это analyst-job применимого репо, у которого есть конфликт»:
|
||||
|
||||
```
|
||||
AND NOT (
|
||||
jobs.agent = 'analyst'
|
||||
{repo_scope} -- "" при пустом CSV (все репо); иначе AND jobs.repo IN (<sanitized>)
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM tasks t2
|
||||
WHERE t2.repo = jobs.repo
|
||||
AND t2.id != jobs.task_id -- «другая» задача (rework-analyst своей же задачи не блокирует себя)
|
||||
AND t2.stage != 'done')
|
||||
OR EXISTS (SELECT 1 FROM repo_freeze f
|
||||
WHERE f.repo = jobs.repo AND f.cleared_at IS NULL)
|
||||
)
|
||||
)
|
||||
```
|
||||
- Гейт **только для `jobs.agent='analyst'`** — вход в анализ. Прочие роли активной задачи проходят
|
||||
(иначе единственная активная задача не сдвинется). Rework-analyst (`start_pipeline` rejection-path,
|
||||
re-enqueue analyst той же задачи) не блокируется собой за счёт `t2.id != jobs.task_id`.
|
||||
- **Relocation среза ветки**: для применимого репо `start_pipeline` создаёт task-row
|
||||
(`stage='analysis'`) и enqueue analyst-job, но **НЕ** зовёт `_create_gitea_branch` /
|
||||
`_create_initial_docs`. Эти два вызова переносятся в путь spawn analyst-job (launcher), выполняясь
|
||||
**в момент claim** — когда `origin/main` уже включает предшественника (gate открылся ⇒ предшественник
|
||||
`done` ⇒ merge-verify ORCH-071 гарантировал SHA-в-main). Последовательность на claim сохраняется
|
||||
идентичной нынешней: `_create_gitea_branch` (от свежего `main`) → `_create_initial_docs` →
|
||||
`ensure_worktree` (fetch+checkout только что созданной ветки) ⇒ база = свежий `origin/main`.
|
||||
- **Идемпотентность**: `_create_gitea_branch` уже обрабатывает 409 «branch exists» как no-op ⇒ повтор
|
||||
claim (рестарт/реклейм) безопасен без флага в БД. AC-6 проверяемо:
|
||||
`git merge-base --is-ancestor <validated_sha A> <base B>`.
|
||||
|
||||
**Почему не «гейт в `start_pipeline` + отложенный re-trigger»** (альтернатива OQ-1.A): webhook Plane —
|
||||
one-shot; отложенная задача потребовала бы отдельного re-trigger (reconciler/done-hook) и псевдо-стадии
|
||||
ожидания → больше состояния, больше путей, выше риск «зависшей» задачи. Relocation на claim
|
||||
переиспользует уже-restart-safe `jobs`-очередь: ожидающая задача = `queued` analyst-job без ветки;
|
||||
открытие gate = обычный claim на ближайшем тике планировщика (AC-2, AC-3 «бесплатно»).
|
||||
|
||||
### D2 — Хранилище freeze: отдельная таблица `repo_freeze` (OQ-2)
|
||||
**Решение: новая аддитивная таблица** `repo_freeze(repo, frozen_at, reason, work_item_id, cleared_at)`
|
||||
(детали — `08-data-requirements.md`). Активный freeze ⇔ `cleared_at IS NULL`. Колонка на `tasks`
|
||||
отвергнута: freeze — **per-repo** сигнал, а деградировавшая задача к этому моменту уже `stage='done'`
|
||||
(BR-7) — привязка к задаче семантически неверна. Таблица — append-only журнал (история заморозок,
|
||||
наблюдаемость), идемпотентная миграция `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
### D3 — Выставление freeze (FR-5)
|
||||
В `stage_engine.run_post_deploy_monitor` в ветке вердикта `DEGRADED` (после реакции
|
||||
`ALERT_ONLY`/`ROLLBACK*`, рядом с `set_issue_blocked`, L1702-1715) — вызов
|
||||
`serial_gate.set_repo_freeze(repo, reason, work_item_id)` (never-raise) + Telegram-алерт
|
||||
«пакет заморожен, следующая задача не стартует до ручного снятия» (reuse `send_telegram`/`_notify_post_deploy`).
|
||||
Freeze **durable** (БД), self-hosting прод **не** рестартится/не роняется (NFR-6) — freeze есть
|
||||
пассивная остановка стартов, не действие над прод.
|
||||
|
||||
### D4 — Снятие freeze: явный админ-эндпоинт (OQ-3)
|
||||
**Решение: `POST /serial-gate/unfreeze` (body/query `repo=<repo>`)** → `serial_gate.clear_repo_freeze(repo)`
|
||||
(ставит `cleared_at=now` всем активным строкам репо) + лог + Telegram-подтверждение. Аутентификация —
|
||||
по существующему админ-механизму сервиса (тот же секрет/доступ, что у управляющих ручек; developer
|
||||
согласует с текущей поверхностью). Альтернативой допускается ручная правка БД
|
||||
(`UPDATE repo_freeze SET cleared_at=…`) — задокументировать в README. Снятие — простое, явное,
|
||||
наблюдаемое (`GET /queue`). Plane-жест как триггер снятия **отвергнут** (перегрузка статусов —
|
||||
анти-паттерн ORCH-059).
|
||||
|
||||
### D5 — Область по умолчанию: все зарегистрированные репо (OQ-5)
|
||||
**Решение: пустой `serial_gate_repos` ⇒ применять ко ВСЕМ репо** (а не self-hosting-only как
|
||||
ORCH-35/43/58). Обоснование: serial e2e и анти-stale-base полезны и enduro-trails (FR-3), у каждого
|
||||
репо свой `main`. Cross-repo независимость сохраняется самим условием (`t2.repo = jobs.repo`). Оператор
|
||||
может сузить область CSV (`ORCH_SERIAL_GATE_REPOS=orchestrator`), если хочет оставить enduro
|
||||
без serial. «Нулевая регрессия для enduro» (BR-8/NFR-4) относится к **выключенному** kill-switch.
|
||||
|
||||
### D6 — Blocked/Needs-Input держит gate закрытым (OQ-4)
|
||||
**Решение (Этап 1): осознанно держит.** Задача в Blocked/Needs-Input имеет `stage != 'done'` ⇒
|
||||
участвует в `EXISTS` ⇒ gate закрыт. Пакет не движется, пока оператор не доведёт задачу до прод или не
|
||||
закроет. Отдельный «вывод из учёта активных» — вне скопа (зафиксировано в AC, BRD §6). Наблюдаемость
|
||||
(`GET /queue` + Telegram-карточка «⏳ ждёт …») делает залипание видимым (R-3).
|
||||
|
||||
### D7 — Конфигурация (`src/config.py`)
|
||||
По образцу `task_deps_*` / `post_deploy_*`:
|
||||
- `serial_gate_enabled: bool = True` (`ORCH_SERIAL_GATE_ENABLED`) — kill-switch. `False` ⇒ `claim` и
|
||||
`start_pipeline` 1:1 как сейчас (ветка режется в `start_pipeline`, gate-фрагмент опущен) — NFR-4/AC-7.
|
||||
- `serial_gate_repos: str = ""` (`ORCH_SERIAL_GATE_REPOS`, CSV) — область; пусто ⇒ все репо (D5).
|
||||
- `serial_gate_freeze_enabled: bool = True` (`ORCH_SERIAL_GATE_FREEZE_ENABLED`) — независимый тумблер
|
||||
freeze-слоя (FR-5) для поэтапного раската; `False` ⇒ freeze не выставляется/не учитывается.
|
||||
- Helper `serial_gate.serial_gate_applies(repo) -> bool` (never-raise): `enabled` + (CSV непуст →
|
||||
членство; иначе True).
|
||||
|
||||
### D8 — Leaf-модуль `src/serial_gate.py` (never-raise)
|
||||
Публичный контракт (вся логика без сети, только БД/config):
|
||||
- `serial_gate_applies(repo) -> bool`
|
||||
- `repo_has_active_task(repo, exclude_task_id=None) -> bool` — `EXISTS tasks stage!='done'`
|
||||
- `is_repo_frozen(repo) -> bool` — **fail-CLOSED** (ошибка/сомнение → `True`, AC-9)
|
||||
- `set_repo_freeze(repo, reason, work_item_id)` / `clear_repo_freeze(repo)`
|
||||
- `build_claim_clause() -> str` — SQL-фрагмент для `claim_next_job` (санитизация repo-токенов
|
||||
`^[A-Za-z0-9._-]+$` перед встраиванием в `IN (...)`; невалидный токен дропается)
|
||||
- `snapshot() -> dict` — per-repo `{active_task, waiting, frozen, frozen_reason, frozen_at}` для `/queue`
|
||||
|
||||
### D9 — Наблюдаемость `GET /queue` (BR-9, AC-10)
|
||||
Аддитивный блок `serial_gate`: `{enabled, repos, per_repo: {<repo>: {active_task:{work_item_id,stage}|null,
|
||||
waiting:[…], frozen:bool, frozen_reason, frozen_at}}}`. never-raise: при ошибке — минимальный словарь
|
||||
с флагами и пустыми данными. Существующие ключи `/queue` не меняются.
|
||||
|
||||
### D10 — Согласование fail-open (claim) ↔ fail-closed (freeze) — NFR-1
|
||||
Два требования действуют на разных слоях, без противоречия:
|
||||
- **Hot-claim, тотальный сбой gate-запроса** ⇒ **fail-OPEN**: весь `serial_gate`-фрагмент строится
|
||||
через `try/except` в `build_claim_clause`; любая ошибка построения → пустой фрагмент → claim как без
|
||||
gate. Заклинивание очереди ВСЕХ проектов (включая enduro) хуже, чем разовый риск stale-base (AC-8).
|
||||
- **Freeze-решение в Python-слое** (`is_repo_frozen`, deferral-решение, snapshot) ⇒ **fail-CLOSED**:
|
||||
невозможность подтвердить отсутствие freeze → считать замороженным, не стартовать (AC-9, безопасность
|
||||
прода). Когда freeze реально выставлен, строка `repo_freeze` существует и блокирует в самом SQL —
|
||||
fail-open в claim касается лишь тотального сбоя запроса (транзиент), что приемлемо.
|
||||
|
||||
---
|
||||
|
||||
## Точки врезки (для разработчика)
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `src/serial_gate.py` | **новый** leaf-модуль (D8) |
|
||||
| `src/db.py` | миграция `repo_freeze` (idempotent); `serial_gate` фрагмент в `claim_next_job` (D1); read-only helper'ы выборки активной задачи/freeze |
|
||||
| `src/config.py` | `serial_gate_enabled` / `serial_gate_repos` / `serial_gate_freeze_enabled` (D7) |
|
||||
| `src/webhooks/plane.py` | `start_pipeline`: для применимого репо **не** звать `_create_gitea_branch`/`_create_initial_docs`; оставить task-row + enqueue analyst (D1) |
|
||||
| `src/agents/launcher.py` | `_spawn` для `agent=='analyst'` применимого репо: материализовать ветку+docs (relocated `_create_gitea_branch`+`_create_initial_docs`) перед `ensure_worktree` (D1) |
|
||||
| `src/stage_engine.py` | `run_post_deploy_monitor` DEGRADED-ветка: `serial_gate.set_repo_freeze(...)` + алерт (D3) |
|
||||
| `src/main.py` | `GET /queue` блок `serial_gate` (D9); `POST /serial-gate/unfreeze` (D4) |
|
||||
| `src/notifications.py` | Telegram-карточка `⏳ ждёт завершения <wi>` (по образцу task_deps), best-effort |
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- AC-6 закрыт **структурно** (ветка не существует до открытия gate) — не «лечение следствия».
|
||||
- AC-2/AC-3 «бесплатны»: ожидание = `queued` analyst-job без ветки в restart-safe `jobs`-очереди;
|
||||
открытие gate = обычный claim. In-memory состояния нет (NFR-3).
|
||||
- Переиспользует проверенные паттерны (claim-gate ORCH-026, leaf never-raise, `/queue`-снимок).
|
||||
- `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/merge-gate/merge-verify/image-freshness/post-deploy/
|
||||
deploy-хук/`max_concurrency` — без изменений (NFR-5/AC-11).
|
||||
- Cross-repo параллелизм сохранён (FR-3/AC-4); enduro при выключенном флаге не затронут (NFR-4).
|
||||
|
||||
### Минусы / ограничения
|
||||
- Срез ветки и `_create_initial_docs` мигрируют из async-`start_pipeline` в sync-путь launcher —
|
||||
developer обязан обернуть Gitea-API вызовы для sync-контекста (httpx sync / `asyncio.run`); риск
|
||||
R-4 (см. `10-tech-risks.md`).
|
||||
- Между `start_pipeline` и claim analyst-job у задачи нет материализованной ветки — потребители,
|
||||
ожидающие ветку до запуска analyst, должны быть проверены (R-5).
|
||||
- Blocked-задача держит пакет (D6) — осознанный размен Этапа 1; требует операторского внимания.
|
||||
- Freeze снимается только вручную — «вечный freeze» при невнимании оператора (R-3, mitigation —
|
||||
наблюдаемость + алерт).
|
||||
|
||||
### Откат
|
||||
Полный откат — `serial_gate_enabled=False` (claim/старт 1:1 как сейчас) и/или
|
||||
`serial_gate_freeze_enabled=False`. Таблица `repo_freeze` инертна при выключенных флагах.
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
- **Гейт в `start_pipeline` + re-trigger при `done`** — больше состояния/путей, выше риск зависания (D1).
|
||||
- **Freeze как колонка `tasks`** — неверная семантика (freeze per-repo, задача уже `done`) (D2).
|
||||
- **Self-hosting-only область** (как ORCH-35/43/58) — лишает enduro анти-stale-base (D5).
|
||||
- **Снятие freeze Plane-жестом** — перегрузка статусов, анти-паттерн ORCH-059 (D4).
|
||||
- **Отдельная таблица очереди ожидания** — избыточно, `jobs`(queued)+gate достаточно (ТЗ §5).
|
||||
|
||||
## Связи
|
||||
- Переиспользует: ORCH-1 (очередь), ORCH-026 (claim-gate, auto_rebase/merge-lease), ORCH-021
|
||||
(post-deploy monitor — источник DEGRADED), ORCH-071/073 (merge-verify ⇒ `done` ⇔ SHA-в-main).
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0017-serial-gate.md`.
|
||||
- Не пересекается с merge-очередью/pre-merge rebase/фазами A/B/C — **вне скопа** Этапа 1.
|
||||
73
docs/work-items/ORCH-088/08-data-requirements.md
Normal file
73
docs/work-items/ORCH-088/08-data-requirements.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 08 — Требования к схеме БД: ORCH-088 (Serial gate, freeze-хранилище)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: architecture
|
||||
Связь: ADR `06-adr/ADR-001-serial-gate.md` (D2/D3/D4), ТЗ `02-trz.md` §5.
|
||||
|
||||
> Общая прод-БД (self-hosting обслуживает enduro-trails из того же инстанса). Все миграции —
|
||||
> **только аддитивные и идемпотентные** (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`). Изменение
|
||||
> существующих таблиц-контрактов (`tasks`, `jobs`, `job_deps`, `agent_runs`) запрещено.
|
||||
|
||||
---
|
||||
|
||||
## 1. Новая таблица `repo_freeze` (FR-5)
|
||||
|
||||
Durable per-repo признак заморозки пакета после post-deploy `DEGRADED`/rollback. Append-only журнал:
|
||||
активная заморозка ⇔ существует строка репо с `cleared_at IS NULL`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS repo_freeze (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo TEXT NOT NULL, -- ключ области (per-repo)
|
||||
frozen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reason TEXT, -- напр. "post-deploy DEGRADED 3/5"
|
||||
work_item_id TEXT, -- задача-источник деградации (уже stage='done')
|
||||
cleared_at TEXT -- NULL = freeze активен; снят оператором → datetime
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_freeze_active ON repo_freeze (repo, cleared_at);
|
||||
```
|
||||
|
||||
### Семантика
|
||||
- **Активный freeze репо `R`:** `EXISTS (SELECT 1 FROM repo_freeze WHERE repo=R AND cleared_at IS NULL)`.
|
||||
- **Выставление** (`set_repo_freeze`): INSERT новой строки (`cleared_at=NULL`). Повторный DEGRADED при
|
||||
уже активном freeze — допускается доп. строка (журнал) либо no-op при существующей активной (выбор
|
||||
разработчика; на gate не влияет — `EXISTS` идемпотентен).
|
||||
- **Снятие** (`clear_repo_freeze`): `UPDATE repo_freeze SET cleared_at=datetime('now')
|
||||
WHERE repo=? AND cleared_at IS NULL` (закрывает все активные строки репо). Идемпотентно (повтор → 0 rows).
|
||||
- **Read (gate/snapshot):** только `cleared_at IS NULL`-строки; `is_repo_frozen` — **fail-closed**
|
||||
(ошибка чтения → `True`, AC-9).
|
||||
|
||||
### Использование в горячем claim
|
||||
`db.claim_next_job` читает `repo_freeze` инлайн внутри `serial_gate`-фрагмента (только локальная БД,
|
||||
offline — NFR-2):
|
||||
```
|
||||
OR EXISTS (SELECT 1 FROM repo_freeze f WHERE f.repo = jobs.repo AND f.cleared_at IS NULL)
|
||||
```
|
||||
(внутри `AND NOT ( jobs.agent='analyst' AND … )` — см. ADR D1). Тотальный сбой построения фрагмента →
|
||||
fail-open для claim (AC-8); реально выставленная строка блокирует через сам SQL.
|
||||
|
||||
---
|
||||
|
||||
## 2. Активная задача репо — без новых колонок
|
||||
|
||||
«Репо занят» вычисляется из существующих столбцов `tasks(repo, stage)`:
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done')
|
||||
```
|
||||
Новых колонок/таблиц для «активной задачи» и «очереди ожидания» **не вводится**: ожидание = существующий
|
||||
`jobs.status='queued'` analyst-job + gate в claim (ТЗ §5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Идемпотентность и restart-safety
|
||||
- Миграция `repo_freeze` выполняется в общем init-пути схемы (`db.init_db`/`_ensure_*`), безопасна к
|
||||
повторному запуску (`IF NOT EXISTS`).
|
||||
- Всё состояние gate/freeze — в БД (нет in-memory) ⇒ после рестарта поведение идентично (NFR-3/AC-3):
|
||||
активная задача определяется из `tasks`, freeze — из `repo_freeze`, ожидающая задача — `queued` job.
|
||||
- При выключенных флагах (`serial_gate_enabled=False` / `serial_gate_freeze_enabled=False`) таблица
|
||||
инертна; enduro и существующие контракты не затрагиваются (NFR-4/AC-11).
|
||||
|
||||
---
|
||||
|
||||
## 4. Неизменяемые контракты
|
||||
`tasks`, `jobs`, `job_deps`, `agent_runs`, `tracker_messages` — схема **без изменений**.
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` — не БД, но также не меняются (NFR-5).
|
||||
29
docs/work-items/ORCH-088/10-tech-risks.md
Normal file
29
docs/work-items/ORCH-088/10-tech-risks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 10 — Технические риски: ORCH-088 (Serial gate)
|
||||
|
||||
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: architecture
|
||||
Связь: ADR `06-adr/ADR-001-serial-gate.md`, ТЗ `02-trz.md` §11-12, BRD §8.
|
||||
|
||||
Оценка: **Вероятность** (Н/С/В) × **Влияние** (Н/С/В). Self-hosting: «Влияние В» = риск для конвейера
|
||||
ВСЕХ проектов (общий прод/БД/очередь).
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| **R-1** | **Stale-base сохраняется**, если ветка режется на входе (`_create_gitea_branch` в `start_pipeline`) до завершения предшественника — гейт только claim не лечит (BRD R-1, AC-6 FAIL). | С | В | Relocation среза ветки на claim analyst-job (ADR D1): `start_pipeline` не создаёт ветку для применимого репо; `ensure_worktree` режет от свежего `origin/main` уже после открытия gate. Тест AC-6: `git merge-base --is-ancestor <validated_sha A> <base B>`. |
|
||||
| **R-2** | Gate, ошибочно **fail-closed на транзиентной ошибке БД** в hot-claim, заклинивает очередь ВСЕХ проектов (enduro встаёт). | С | В | `build_claim_clause` обёрнут в try/except → ошибка построения = пустой фрагмент = claim без gate (**fail-open**, ADR D10/AC-8). Freeze fail-closed применяется только в Python-слое, не в тотальном сбое hot-claim. Unit-тест: исключение в построении clause → claim не падает, выбирает job. |
|
||||
| **R-3** | **«Вечный freeze» / залипшая Blocked-задача** останавливает пакет незаметно (D6 — Blocked держит gate). | С | С | Наблюдаемость `GET /queue` блок `serial_gate` (`active_task`, `waiting`, `frozen`+reason); Telegram-алерт при выставлении freeze и карточка «⏳ ждёт …»; ручное снятие — простой эндпоинт `POST /serial-gate/unfreeze` (D4). Оператор видит причину застоя. |
|
||||
| **R-4** | **Async→sync релокация** `_create_gitea_branch`/`_create_initial_docs`: эти вызовы async (httpx) в `start_pipeline`, а launcher `_spawn` — sync. Неверная обёртка → исключение/блок event-loop. | С | С | Developer оборачивает Gitea-API для sync-контекста (httpx sync client / `asyncio.run` в отдельном пути). Контракт launcher never-raise: сбой материализации ветки → лог + job в retry, прод не трогается. Тест: claim analyst-job создаёт ветку и worktree без падения. |
|
||||
| **R-5** | **Потребитель ожидает материализованную ветку до запуска analyst** (между `start_pipeline` и claim ветки нет): Telegram-карточка / Plane-sync / reconciler могут предполагать существование ветки. | Н | С | Проверено: трекер/Plane-sync используют branch как строку имени, не git-ref. Перед разработкой — аудит читателей `tasks.branch`/Gitea-ветки на стадии до analyst. `start_pipeline` по-прежнему пишет `branch` в task-row (имя), не материализуя ref. |
|
||||
| **R-6** | **SQL-инъекция / поломка clause** через `serial_gate_repos` CSV при встраивании в `IN (...)`. | Н | С | Санитизация repo-токенов `^[A-Za-z0-9._-]+$` в `build_claim_clause` (ADR D8); невалидный токен дропается. CSV — операторский конфиг (не пользовательский ввод), риск низкий, но гард обязателен. Unit-тест на мусорный CSV. |
|
||||
| **R-7** | **Rework-analyst блокирует сам себя**: rejection-path `start_pipeline` re-enqueue analyst активной задачи; наивный gate «есть незавершённая задача репо» удержал бы её навсегда. | С | В | Условие `t2.id != jobs.task_id` (ADR D1) — учитываются только **другие** задачи. Unit-тест: rework-analyst задачи A при единственной незавершённой A — claim проходит. |
|
||||
| **R-8** | **Freeze не учитывает уже-`done` задачу**: деградировавшая задача к моменту DEGRADED уже `stage='done'` (BR-7) ⇒ обычный gate её не удержит, следующая стартует до выставления freeze (гонка). | Н | С | Freeze — **отдельный durable сигнал** (`repo_freeze`), не зависит от `stage` (ADR D2/D3). `set_repo_freeze` вызывается в DEGRADED-ветке монитора; до снятия gate закрыт безусловно. Возможная узкая гонка «`done`→claim next до записи freeze» приемлема Этапом 1 (следующий тик уже видит freeze); при необходимости — выставлять предупредительный freeze в начале окна мониторинга (вне скопа). |
|
||||
| **R-9** | **Default-all область** неожиданно сериализует enduro (меняет поведение при включении флага). | С | Н | Осознанное решение (ADR D5, ТЗ §8): enduro выигрывает от serial e2e; `max_concurrency=1` и так ограничивает параллелизм. Оператор может сузить `ORCH_SERIAL_GATE_REPOS=orchestrator`. «Нулевая регрессия» гарантирована при **выключенном** kill-switch (NFR-4). |
|
||||
| **R-10** | **Миграция на общей прод-БД** (`repo_freeze`) роняет init при неудачном порядке/блокировке. | Н | В | `CREATE TABLE IF NOT EXISTS` + idempotent index; выполняется в существующем init-пути схемы; аддитивно, не трогает enduro-строки. Прод-контейнер не рестартится механизмом gate (NFR-6). |
|
||||
|
||||
---
|
||||
|
||||
## Сводный вывод
|
||||
Архитектурно безопасных блокеров нет. Критические векторы — **R-1** (закрыт relocation среза ветки),
|
||||
**R-2/R-7** (закрыты fail-open hot-claim и `t2.id != jobs.task_id`). Все механизмы аддитивны, под
|
||||
kill-switch, never-raise, не рестартят прод. Главный операционный риск — **R-3** (ручной freeze),
|
||||
смягчён наблюдаемостью и алертами. Реализация — стандартный путь стадии development без эскалации
|
||||
`arch:major-change` (нет новой стадии/QG/смены БД-контракта; новая таблица аддитивна).
|
||||
85
docs/work-items/ORCH-088/12-review.md
Normal file
85
docs/work-items/ORCH-088/12-review.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-088
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-088 — Per-repo serial gate (Этап 1, serial e2e)
|
||||
|
||||
## Summary
|
||||
PR реализует per-repo serial gate (FR-1…FR-5) тремя согласованными механизмами в полном
|
||||
соответствии с ТЗ и ADR-001: gate-в-claim (`db.claim_next_job`), отложенный срез ветки
|
||||
(`start_pipeline` → `launcher._materialize_deferred_branch`) и durable rollback-freeze
|
||||
(`repo_freeze` + `POST /serial-gate/unfreeze`). Чистая логика вынесена в leaf-модуль
|
||||
`src/serial_gate.py` (never-raise). Полный прогон `pytest tests/ -q` — **1114 passed**;
|
||||
профильные сюиты (`test_serial_gate*`, `test_queue_endpoint`, `test_plane_webhook`,
|
||||
`test_status_trigger`) — 33 passed. Документация обновлена в том же PR. Блокеров нет.
|
||||
|
||||
## Оси проверки
|
||||
|
||||
### 1. Соответствие ТЗ / AC
|
||||
- FR-1 (gate на входе в анализ) — gate-фрагмент в `claim_next_job`, только `jobs.agent='analyst'`,
|
||||
только локальная БД (NFR-2). AC-1 ✓
|
||||
- FR-2 (очередь e2e, FIFO) — реализация уточняет псевдо-SQL ADR `t2.id != jobs.task_id` на
|
||||
`t2.id < jobs.task_id`. Уточнение **корректно и обосновано** (при `!=` пакет одновременно
|
||||
созданных задач взаимно блокируется → дедлок); задокументировано в коде, CHANGELOG и README.
|
||||
AC-2 ✓
|
||||
- FR-3 (per-repo) — все выборки фильтруются `t2.repo = jobs.repo`; cross-repo параллелизм
|
||||
сохранён. AC-4 ✓
|
||||
- FR-4 (restart-safe) — активная задача из `tasks`, freeze в `repo_freeze`; in-memory состояния
|
||||
нет. AC-3 ✓
|
||||
- FR-5 (rollback-freeze) — `set_repo_freeze` в DEGRADED-ветке `run_post_deploy_monitor` +
|
||||
Telegram-алерт; ручное снятие `POST /serial-gate/unfreeze`. AC-5 ✓
|
||||
- AC-6 (анти-stale-base) — закрыт **структурно**: ветка не создаётся до открытия gate
|
||||
(deferred cut в `_materialize_deferred_branch` от свежего `origin/main`). ✓
|
||||
- AC-7 (kill-switch/нулевая регрессия), AC-8 (fail-OPEN claim), AC-9 (fail-CLOSED freeze),
|
||||
AC-10 (`/queue` блок), AC-11 (инварианты) — все подтверждены кодом и тестами.
|
||||
|
||||
### 2. Соответствие ADR
|
||||
- D1–D10 реализованы как описано. Единственное отклонение — FIFO-условие `<` вместо `!=`
|
||||
(D1) — улучшает ADR, устраняет дедлок, явно задокументировано. Глобальный ADR
|
||||
`adr-0017-serial-gate.md` заведён и зарегистрирован.
|
||||
- `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / merge-gate / merge-verify / image-freshness /
|
||||
post-deploy / exit-коды хука — без изменений (AC-11). ✓
|
||||
|
||||
### 3. Качество кода
|
||||
- Leaf-модуль `src/serial_gate.py` — строгий never-raise; корректно разнесены направления
|
||||
отказа: claim — fail-OPEN (`build_claim_clause` → `""`), freeze — fail-CLOSED
|
||||
(`is_repo_frozen` → `True`). Санитизация repo-токенов `^[A-Za-z0-9._-]+$` перед встраиванием
|
||||
в SQL `IN (...)`.
|
||||
- Миграция `repo_freeze` аддитивна и идемпотентна (`CREATE TABLE/INDEX IF NOT EXISTS`).
|
||||
- `_materialize_deferred_branch` исполняется в worker-потоке (нет running loop) → `asyncio.run`
|
||||
безопасен; Gitea-вызовы идемпотентны (409/422 → no-op) → реклейм/рестарт безопасны; transient
|
||||
Gitea-ошибка пробрасывается → job переочередь (нет half-cut состояния).
|
||||
- Docstrings содержательны на всех публичных функциях.
|
||||
|
||||
### 4. Качество тестов
|
||||
Содержательные сюиты покрывают gate (claim), deferred branch, e2e, freeze, `/queue`-snapshot,
|
||||
webhook и status-trigger. Тесты не тривиальны (проверяют поведение, а не факт вызова).
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет — отмечено лишь как наблюдение) `_materialize_deferred_branch` делает два отдельных
|
||||
`asyncio.run` подряд; функционально корректно, можно объединить в один loop при будущем
|
||||
рефакторинге. Не блокирует.
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR — правило golden-source (CLAUDE.md §2) выполнено:
|
||||
- `docs/architecture/README.md` — новый раздел «Per-repo serial gate (ORCH-088)», обновлены
|
||||
таблица API (`GET /queue` + новый `POST /serial-gate/unfreeze`), раздел БД (`repo_freeze`),
|
||||
строка статуса доработок.
|
||||
- `CLAUDE.md` — абзац о serial-режиме в разделе «Очередь задач».
|
||||
- `CHANGELOG.md` — запись `feat:` (ORCH-088).
|
||||
- `.env.example` — три новых флага с описанием.
|
||||
- `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md` + сквозной
|
||||
`docs/architecture/adr/adr-0017-serial-gate.md` + `08-data-requirements.md`.
|
||||
|
||||
Изменения `src/` полностью отражены в документации → требование Reviewer §4 удовлетворено.
|
||||
94
docs/work-items/ORCH-088/13-test-report.md
Normal file
94
docs/work-items/ORCH-088/13-test-report.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-088
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-088 (Per-repo serial gate, Этап 1: serial e2e)
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; mode=AUTO)
|
||||
- Repo / ветка: `orchestrator` / `feature/ORCH-088-orch-88-10-20`
|
||||
- Дата: 2026-06-09T08:19Z
|
||||
|
||||
## Результаты
|
||||
|
||||
### Полный регресс
|
||||
`python -m pytest tests/ -v --tb=short` → **1114 passed, 1 warning, 31.52s**.
|
||||
Единственное предупреждение — известный `PydanticDeprecatedSince20` в `src/config.py:5`
|
||||
(не относится к ORCH-088).
|
||||
|
||||
### Профильные сюиты ORCH-088 (24 теста, 0 fail)
|
||||
`test_serial_gate*`, `test_queue_endpoint` → **24 passed, 1.39s**.
|
||||
|
||||
### Сопоставление с тест-планом `04-test-plan.yaml`
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-01 | gate закрыт при активной задаче (claim не берёт analyst B) | `test_serial_gate::test_gate_closed_when_repo_has_active_task` | PASS |
|
||||
| TC-02 | `serial_gate_applies`: enabled+пустой CSV/членство/вне CSV | `test_serial_gate::test_serial_gate_applies_scopes` | PASS |
|
||||
| TC-03 | job'ы уже активной задачи gate'ом не блокируются | `test_serial_gate::test_non_analyst_job_of_active_task_passes` | PASS |
|
||||
| TC-04 | автостарт B после A.stage='done' | `test_serial_gate_e2e::test_next_starts_automatically_when_predecessor_done` | PASS |
|
||||
| TC-05 | очередь из 3 задач — строго по одной, FIFO по jobs.id | `test_serial_gate_e2e::test_three_tasks_processed_one_at_a_time_fifo` | PASS |
|
||||
| TC-06 | restart-safe: активная задача из БД | `test_serial_gate_e2e::test_restart_safe_active_task_from_db` | PASS |
|
||||
| TC-07 | freeze переживает рестарт | `test_serial_gate_freeze::test_freeze_survives_restart` | PASS |
|
||||
| TC-08 | per-repo: orchestrator не блокирует enduro-trails | `test_serial_gate::test_per_repo_isolation` | PASS |
|
||||
| TC-09 | freeze orchestrator не влияет на enduro-trails | `test_serial_gate_freeze::test_freeze_is_per_repo` | PASS |
|
||||
| TC-10 | post-deploy DEGRADED → durable freeze + Telegram-алерт | `test_serial_gate_freeze::test_post_deploy_degraded_sets_freeze_and_alerts` | PASS |
|
||||
| TC-11 | freeze гейтит даже без задач stage<done (BR-7) | `test_serial_gate_freeze::test_freeze_gates_even_without_unfinished_task` | PASS |
|
||||
| TC-12 | ручное снятие freeze → следующая стартует | `test_serial_gate_freeze::test_manual_unfreeze_lets_next_start` | PASS |
|
||||
| TC-13 | ветка B не создаётся пока gate закрыт (отсрочка среза) | `test_serial_gate_branch::test_branch_cut_deferred_when_gate_applies` | PASS |
|
||||
| TC-14 | база B = origin/main с кодом A (merge-base ancestor) | `test_serial_gate_branch::test_deferred_branch_base_contains_predecessor` | PASS |
|
||||
| TC-15 | kill-switch off → claim инертен, нулевая регрессия | `test_serial_gate::test_kill_switch_off_is_inert` | PASS |
|
||||
| TC-16 | репо вне CSV → gate не применяется | `test_serial_gate::test_repo_outside_csv_not_gated` | PASS |
|
||||
| TC-17 | ошибка БД в claim → fail-OPEN, не падает | `test_serial_gate::test_build_clause_error_fails_open` | PASS |
|
||||
| TC-18 | ошибка freeze → fail-CLOSED | `test_serial_gate_freeze::test_is_repo_frozen_fails_closed` | PASS |
|
||||
| TC-19 | snapshot() shape + never-raise | `test_serial_gate::test_snapshot_shape_and_never_raises` | PASS |
|
||||
| TC-20 | GET /queue: блок serial_gate, существующие ключи не тронуты | `test_queue_endpoint::test_queue_has_serial_gate_block_and_keeps_existing_keys` + `::test_queue_serial_gate_reflects_freeze` | PASS |
|
||||
| TC-21 | STAGE_TRANSITIONS / QG_CHECKS не изменены | `test_serial_gate::test_registries_unchanged` | PASS |
|
||||
| TC-22 | миграция repo_freeze идемпотентна | `test_serial_gate_freeze::test_repo_freeze_migration_idempotent` | PASS |
|
||||
|
||||
Дополнительно покрыт kill-switch-путь среза ветки:
|
||||
`test_serial_gate_branch::test_branch_cut_immediate_when_kill_switch_off` — PASS.
|
||||
|
||||
**Покрытие тест-плана: 22/22 TC выполнены, все PASS.**
|
||||
|
||||
### Сопоставление с критериями приёмки `03-acceptance-criteria.md`
|
||||
| AC | Покрывающие TC | Результат |
|
||||
|----|----------------|-----------|
|
||||
| AC-1 (gate закрыт при активной) | TC-01 | PASS |
|
||||
| AC-2 (автостарт по done) | TC-04, TC-05 | PASS |
|
||||
| AC-3 (restart-safe) | TC-06, TC-07 | PASS |
|
||||
| AC-4 (per-repo) | TC-08, TC-09 | PASS |
|
||||
| AC-5 (rollback-freeze + алерт) | TC-10, TC-11, TC-12 | PASS |
|
||||
| AC-6 (нет stale-base) | TC-13, TC-14 | PASS |
|
||||
| AC-7 (kill-switch / нулевая регрессия) | TC-15, TC-16 | PASS |
|
||||
| AC-8 (fail-open claim) | TC-17 | PASS |
|
||||
| AC-9 (fail-closed freeze) | TC-18 | PASS |
|
||||
| AC-10 (наблюдаемость /queue) | TC-19, TC-20 | PASS |
|
||||
| AC-11 (инварианты неизменны) | TC-21, TC-22 | PASS |
|
||||
|
||||
## Smoke test API (prod 8500, read-only)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}`
|
||||
- `GET /status` → 200, отдаёт активные задачи (валидный JSON)
|
||||
- `GET /queue` → 200, валидный JSON; присутствуют блоки `reconcile`/`reaper`/
|
||||
`post_deploy`/`merge_verify`/`task_deps`/`recent`.
|
||||
|
||||
Примечание: блок `serial_gate` в ответе прод-`/queue` (8500) **отсутствует**, т.к. на
|
||||
проде сейчас работает код до ORCH-088 (фича ещё не задеплоена — это и есть тестируемая
|
||||
задача). Наличие и форма нового блока подтверждены интеграционным тестом TC-20 через
|
||||
TestClient на коде ветки. Деструктивных операций на прод-контейнере не выполнялось.
|
||||
|
||||
## Вывод pytest (хвост)
|
||||
```
|
||||
======================= 1114 passed, 1 warning in 31.52s =======================
|
||||
```
|
||||
Профильные сюиты:
|
||||
```
|
||||
======================== 24 passed, 1 warning in 1.39s =========================
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс зелёный (1114 passed), все 22 TC тест-плана выполнены и PASS,
|
||||
все 11 AC покрыты, smoke API OK. Задача готова к переходу на стадию `deploy-staging`.
|
||||
28
docs/work-items/ORCH-088/15-staging-log.md
Normal file
28
docs/work-items/ORCH-088/15-staging-log.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-09T08:23:42Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live staging environment
|
||||
(`orchestrator-staging`, 8501), run canonically inside the container
|
||||
(`scripts/staging_check.py --base-url http://localhost:8501 --mode stub`).
|
||||
|
||||
**Verdict: SUCCESS** (exit code 0).
|
||||
|
||||
Result: 8/10 checks PASS. All REAL (pipeline) checks are green:
|
||||
- Block A SMOKE: A1, A2, A3 — PASS
|
||||
- Block B ACCESS: B4, B5, B6 (registry isolation: sandbox present, prod ET/ORCH absent) — PASS
|
||||
- Block C E2E: C7 (create issue in SANDBOX), C8 (trigger pipeline) — PASS
|
||||
|
||||
The two failed checks are known sandbox-infra checks (depend on SANDBOX bot
|
||||
accounts being project members, not on the pipeline) and were waived per
|
||||
ORCH-061 (`staging_infra_tolerance_enabled=True`); the script still exited 0
|
||||
fail-closed because every REAL check is 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
|
||||
```
|
||||
@@ -158,12 +158,50 @@ def resolve_agent_model(agent: str, project_id: str = None) -> str:
|
||||
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,
|
||||
@@ -171,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}' "
|
||||
@@ -180,6 +223,16 @@ def resolve_agent_effort(agent: str, project_id: str = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _run_log_path(run_id):
|
||||
"""Absolute path of a per-run agent log: ``<settings.runs_dir>/<run_id>.log``.
|
||||
|
||||
ORCH-087: single source of truth for the log path so it follows
|
||||
``settings.runs_dir`` everywhere (no hardcoded ``/app/data/runs``), which keeps
|
||||
``_spawn`` writable on non-container hosts (CI) where ``/app`` is inaccessible.
|
||||
"""
|
||||
return os.path.join(settings.runs_dir, f"{run_id}.log")
|
||||
|
||||
|
||||
def prune_run_logs(runs_dir, keep_days=30, keep_max=500, active_paths=None):
|
||||
"""L-2: best-effort rotation of per-run logs (<runs_dir>/*.log).
|
||||
|
||||
@@ -365,6 +418,32 @@ class AgentLauncher:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _materialize_deferred_branch(
|
||||
self, repo: str, branch: str, work_item_id: str | None, title: str | None
|
||||
) -> None:
|
||||
"""ORCH-088 (ADR-001 D1): create the deferred Gitea branch + initial docs.
|
||||
|
||||
Relocated from ``webhooks.plane.start_pipeline``: the two coroutines are run
|
||||
SYNCHRONOUSLY here (this method executes in the worker THREAD — no running
|
||||
event loop — so ``asyncio.run`` is safe, R-4). Sequence mirrors the original
|
||||
start_pipeline order so the downstream worktree/PR flow is identical:
|
||||
``_create_gitea_branch`` (from a fresh ``main``) -> ``_create_initial_docs``.
|
||||
Both are idempotent (409/422 -> no-op) so a re-claim after a restart is safe.
|
||||
A transient Gitea error PROPAGATES so the caller (_spawn) fails the launch and
|
||||
the queue worker requeues the job for a later tick (never a half-cut state).
|
||||
"""
|
||||
import asyncio
|
||||
from ..webhooks.plane import _create_gitea_branch, _create_initial_docs
|
||||
|
||||
name = title or work_item_id or branch
|
||||
logger.info(
|
||||
f"ORCH-088: materialising deferred branch '{branch}' for {repo} "
|
||||
f"({work_item_id}) at analyst-job claim"
|
||||
)
|
||||
asyncio.run(_create_gitea_branch(repo, branch))
|
||||
if work_item_id:
|
||||
asyncio.run(_create_initial_docs(repo, branch, work_item_id, name))
|
||||
|
||||
def _spawn(self, agent: str, repo: str, task_content: str = None,
|
||||
task_id: int = None, job_id: int = None) -> int:
|
||||
"""Shared spawn implementation for launch() and launch_job().
|
||||
@@ -384,9 +463,33 @@ class AgentLauncher:
|
||||
raise FileNotFoundError(f"Repo not found: {local_repo_path}")
|
||||
|
||||
# Determine branch (needed before we touch the worktree / task file).
|
||||
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
|
||||
_br_row = (
|
||||
get_db().execute(
|
||||
"SELECT branch, work_item_id, title FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
if task_id else None
|
||||
)
|
||||
agent_branch = _br_row[0] if _br_row else "main"
|
||||
|
||||
# ORCH-088 (FR-1/AC-6, ADR-001 D1): materialise a DEFERRED branch cut. When
|
||||
# the serial gate applies, start_pipeline did NOT create the Gitea branch /
|
||||
# initial docs — they were deferred to this claim so the cut happens from a
|
||||
# fresh origin/main that already contains the predecessor. We only reach this
|
||||
# claim because the gate is OPEN (predecessor done), so it is now safe. This
|
||||
# runs ONLY for the analyst-job (pipeline entry); every later stage reuses the
|
||||
# existing branch. Idempotent (409/422 -> no-op) so a re-claim is safe. On a
|
||||
# transient Gitea error this raises -> _drain_once requeues the job (R-4).
|
||||
if agent == "analyst" and _br_row is not None:
|
||||
try:
|
||||
from .. import serial_gate
|
||||
_applies = serial_gate.serial_gate_applies(repo)
|
||||
except Exception: # noqa: BLE001 - never let the gate check block a launch
|
||||
_applies = False
|
||||
if _applies:
|
||||
self._materialize_deferred_branch(
|
||||
repo, agent_branch, _br_row[1], _br_row[2]
|
||||
)
|
||||
|
||||
# ORCH-41: resolve the Plane project uuid for this repo so per-project
|
||||
# model/effort overrides apply. Unknown repo -> None (env/default only).
|
||||
from ..projects import get_project_by_repo
|
||||
@@ -418,7 +521,7 @@ class AgentLauncher:
|
||||
conn.commit()
|
||||
|
||||
# Prepare output log path
|
||||
output_path = f"/app/data/runs/{run_id}.log"
|
||||
output_path = _run_log_path(run_id)
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
# Build the claude command
|
||||
@@ -430,6 +533,19 @@ class AgentLauncher:
|
||||
# (project-override > per-agent env > default), not hardcoded in AGENT_CONFIGS.
|
||||
model = resolve_agent_model(agent, project_id)
|
||||
effort = resolve_agent_effort(agent, project_id)
|
||||
# ORCH-087 (BR-EFF): stamp the REAL --effort value onto this agent_runs row
|
||||
# in the moment of launch. The CLI does not echo effort in its result JSON,
|
||||
# so this is the only reliable source for the tracker's "· model · effort"
|
||||
# line. Empty resolve (no --effort flag) -> NULL so the suffix is omitted.
|
||||
# Reuses the still-open conn; never blocks the launch.
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_runs SET effort=? WHERE id=?",
|
||||
(effort or None, run_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"effort stamp failed for run_id={run_id}: {e}")
|
||||
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
|
||||
@@ -767,7 +883,7 @@ class AgentLauncher:
|
||||
if task_row and agent != "deployer": # deployer handled above
|
||||
_tid, _wid = task_row
|
||||
from ..notifications import send_telegram, link_for
|
||||
send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
|
||||
send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: {_run_log_path(run_id)}")
|
||||
|
||||
# Feature 4 + ORCH-016: post the unified per-agent status comment under
|
||||
# that agent's bot, threading the wall-clock duration we just measured
|
||||
@@ -829,7 +945,7 @@ class AgentLauncher:
|
||||
|
||||
# Classify the failure from the agent log tail (no token cost).
|
||||
kind, retry_after = "permanent", None
|
||||
log_path = output_path or f"/app/data/runs/{run_id}.log"
|
||||
log_path = output_path or _run_log_path(run_id)
|
||||
try:
|
||||
kind, retry_after = classify_log_file(log_path)
|
||||
except Exception:
|
||||
@@ -892,7 +1008,7 @@ class AgentLauncher:
|
||||
from ..notifications import send_telegram
|
||||
send_telegram(
|
||||
f"\U0001f6a8 Job {job_id} ({agent}, repo {job.get('repo')}) "
|
||||
f"failed: {why}. Logs: /app/data/runs/{run_id}.log"
|
||||
f"failed: {why}. Logs: {_run_log_path(run_id)}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1034,35 +1150,28 @@ class AgentLauncher:
|
||||
return None
|
||||
|
||||
def _ensure_pr(self, repo: str, branch: str, run_id: int):
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
base_url = f"{settings.gitea_url}/api/v1"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/repos/{owner}/{repo}/pulls",
|
||||
params={"state": "open", "head": branch},
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
prs = resp.json()
|
||||
if prs:
|
||||
return prs[0]["number"]
|
||||
parts = branch.split("/")
|
||||
title = parts[-1] if parts else branch
|
||||
resp = httpx.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/pulls",
|
||||
json={"title": f"feat: {title}", "head": branch, "base": "main",
|
||||
"body": f"Auto-created by orchestrator after developer run_id={run_id}"},
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
pr_number = resp.json()["number"]
|
||||
logger.info(f"Created PR #{pr_number} for {branch}")
|
||||
return pr_number
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PR for {branch}: {e}")
|
||||
return None
|
||||
"""Ensure an open code-PR exists for ``branch``; return its number or None.
|
||||
|
||||
ORCH-082 (ADR-001 Р-4): delegated to the single idempotent PR-creation actor
|
||||
``merge_gate.ensure_open_pr`` so PR creation lives in ONE place and logs the
|
||||
same created/existed/failed outcomes (G3). The CALL TRIGGER is unchanged — the
|
||||
caller (`_monitor_agent`) still invokes this ONLY on the developer path with a
|
||||
fresh worktree commit; only the implementation under the hood is shared. The
|
||||
actor uses the same ``head==branch AND base==main`` filter as ``merge_pr``, so
|
||||
the developer-created PR and the one merge-verify merges are guaranteed to be
|
||||
the same code-PR. Never raises (the actor is never-raise); ``failed`` -> None,
|
||||
preserving the previous "best-effort, return None on failure" contract.
|
||||
"""
|
||||
from .. import merge_gate
|
||||
status, detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(f"_ensure_pr({branch}, run_id={run_id}) -> {status} ({detail})")
|
||||
if status in ("created", "existed"):
|
||||
try:
|
||||
return int(detail)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
logger.error(f"Failed to ensure PR for {branch}: {detail}")
|
||||
return None
|
||||
|
||||
def _write_task_file(self, repo: str, branch: str, task_file: str, content: str):
|
||||
"""Write task file directly into the task's worktree.
|
||||
|
||||
@@ -44,6 +44,10 @@ class Settings(BaseSettings):
|
||||
repos_dir: str = "/repos"
|
||||
host_repos_dir: str = "/home/slin/repos"
|
||||
worktrees_dir: str = "/repos/_wt" # ORCH-2 / S-4: isolated worktree per task/branch
|
||||
# ORCH-087: base dir for per-run agent logs (<runs_dir>/<run_id>.log). Lifted out
|
||||
# of the hardcoded '/app/data/runs' so tests (and any non-container host) can point
|
||||
# it at a writable path; default preserves the container layout.
|
||||
runs_dir: str = "/app/data/runs"
|
||||
|
||||
# DB
|
||||
db_path: str = "/app/data/orchestrator.db"
|
||||
@@ -97,13 +101,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"
|
||||
@@ -427,6 +433,31 @@ class Settings(BaseSettings):
|
||||
task_deps_enabled: bool = True
|
||||
task_deps_source: str = "db"
|
||||
|
||||
# ORCH-088 (Этап 1, serial e2e): per-repo serial gate. A new task's analyst-job
|
||||
# does NOT enter analysis (no branch cut, no analyst agent) while the same repo
|
||||
# has another unfinished task (tasks.stage != 'done') OR the repo is frozen
|
||||
# (repo_freeze). The gate lives in claim_next_job (offline-safe hot path, like
|
||||
# the ORCH-026 dep-gate) + the branch cut is deferred from start_pipeline to the
|
||||
# analyst-job claim (launcher) so the branch base is always a fresh origin/main
|
||||
# that already contains the predecessor (anti-stale-base, AC-6). All additive,
|
||||
# never-raise, restart-safe; STAGE_TRANSITIONS / QG_CHECKS unchanged. See
|
||||
# docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md.
|
||||
# serial_gate_enabled -> kill-switch (env ORCH_SERIAL_GATE_ENABLED).
|
||||
# False -> claim_next_job AND start_pipeline are 1:1
|
||||
# as before ORCH-088 (clause omitted, branch cut in
|
||||
# start_pipeline) — zero regression (AC-7).
|
||||
# serial_gate_repos -> CSV scope (env ORCH_SERIAL_GATE_REPOS). Empty ->
|
||||
# applies to ALL registered repos (D5); non-empty ->
|
||||
# only the listed repos. Repo tokens are sanitised
|
||||
# (^[A-Za-z0-9._-]+$) before being embedded in SQL.
|
||||
# serial_gate_freeze_enabled-> independent tumbler for the FR-5 rollback-freeze
|
||||
# layer (env ORCH_SERIAL_GATE_FREEZE_ENABLED). False
|
||||
# -> freeze is neither set (post-deploy DEGRADED) nor
|
||||
# consulted in the claim gate.
|
||||
serial_gate_enabled: bool = True
|
||||
serial_gate_repos: str = ""
|
||||
serial_gate_freeze_enabled: bool = True
|
||||
|
||||
# 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
|
||||
@@ -440,6 +471,22 @@ class Settings(BaseSettings):
|
||||
# merge_verify_repos), so non-self repos are a no-op.
|
||||
regression_guard_enabled: bool = True
|
||||
|
||||
# ORCH-082 (ADR-001 Р-5): guarantee an open code-PR BEFORE the deterministic
|
||||
# merge_pr inside the merge-verify under-gate. The pipeline never guaranteed the
|
||||
# branch had an open PR (head==branch, base==main) at merge time — PRs are created
|
||||
# ONLY on the developer path with a fresh worktree commit (launcher._ensure_pr),
|
||||
# so a branch (e.g. after a manual main restore / a bounce with no new commits)
|
||||
# could reach merge-verify PR-less -> merge_pr returns "no open PR" -> a FALSE HOLD
|
||||
# that ORCH-073 fail-closed correctly catches but should never have to. The
|
||||
# idempotent leaf-actor merge_gate.ensure_open_pr creates/finds the code-PR ДО
|
||||
# merge_pr; ORCH-073's SHA-in-main proof is untouched and stays authoritative.
|
||||
# merge_verify_autocreate_pr_enabled -> kill-switch (env
|
||||
# ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED). False -> exactly the pre-ORCH-082
|
||||
# behaviour (no auto-create; "no open PR" -> HOLD as before). Reuses the
|
||||
# merge_verify_applies scope (self-hosting / merge_verify_repos) — no separate
|
||||
# *_repos, since auto-create is semantically inseparable from merge-verify.
|
||||
merge_verify_autocreate_pr_enabled: bool = True
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
@@ -467,6 +514,14 @@ class Settings(BaseSettings):
|
||||
tracker_live_status_ttl_s: int = 60
|
||||
tracker_live_status_timeout_s: int = 3
|
||||
|
||||
# ORCH-087 (BR-G5, ADR-001 Р-6): cap for the human BRD-review time shown on the
|
||||
# done card ("твоё {review}"). The brd_review clock can stay open for hours on a
|
||||
# desync (In Review -> Backlog), which made "твоё время" report anomalous stalls
|
||||
# (ORCH-087: 392m). Above this cap the value is shown capped with a "~" marker so
|
||||
# an abnormal stall is never presented as real human review time. Env
|
||||
# ORCH_TRACKER_BRD_REVIEW_CAP_S; default 7200s (2h). 0/negative -> no cap.
|
||||
tracker_brd_review_cap_s: int = 7200
|
||||
|
||||
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char
|
||||
# cap was a hygiene limit, not structural (slug is cut to [:30] independently,
|
||||
# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default
|
||||
|
||||
123
src/db.py
123
src/db.py
@@ -109,6 +109,12 @@ def init_db():
|
||||
# can render a short model tag per stage. Parsed from the run-log result JSON
|
||||
# (modelUsage key) by the launcher monitor; NULL when unknown. Idempotent ALTER.
|
||||
_ensure_column(conn, "agent_runs", "model", "TEXT")
|
||||
# ORCH-087 (BR-EFF): persist the REAL --effort value sent to the Claude CLI per
|
||||
# agent_runs row (low|medium|high|xhigh|max) so the tracker can render the
|
||||
# resolved effort next to the model ("· opus-4-8 · xhigh"). Stamped in
|
||||
# launcher._spawn right after resolve_agent_effort; NULL when no --effort flag
|
||||
# was passed (resolved to "") or for historical rows. Idempotent ALTER.
|
||||
_ensure_column(conn, "agent_runs", "effort", "TEXT")
|
||||
# Telegram live tracker: one editable Telegram message per task. We store its
|
||||
# message_id so each stage transition can editMessageText the same message
|
||||
# instead of spamming a new one. Idempotent ALTER (safe on the live prod DB).
|
||||
@@ -141,6 +147,47 @@ def init_db():
|
||||
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);
|
||||
""")
|
||||
# ORCH-087 (BR-G1, ADR-001 Р-1): authoritative ledger of EVERY tracker card
|
||||
# (Telegram message_id) ever created for a task. The scalar
|
||||
# tasks.tracker_message_id only ever knew the LAST mid, so any lost reference
|
||||
# (delete-fail+send-ok, race, restart) orphaned older cards forever. This
|
||||
# ledger lets every bump delete ALL still-open mids (deleted_at IS NULL), not
|
||||
# just the last one. tasks.tracker_message_id is KEPT (current-card pointer,
|
||||
# full BC). Purely ADDITIVE (CREATE TABLE/INDEX IF NOT EXISTS) -> idempotent,
|
||||
# restart-safe on the live shared prod DB (enduro-trails data untouched). The
|
||||
# logical FK on tasks.id is intentional (no REFERENCES, mirrors job_deps) so
|
||||
# the migration cannot fail on a pre-existing DB. See 08-data-requirements.md.
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS tracker_messages (
|
||||
task_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
PRIMARY KEY (task_id, message_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_messages_open
|
||||
ON tracker_messages(task_id) WHERE deleted_at IS NULL;
|
||||
""")
|
||||
# ORCH-088 (FR-5, ADR-001 D2): durable per-repo rollback-freeze. After a
|
||||
# post-deploy DEGRADED verdict the repo is frozen so the serial gate stays
|
||||
# CLOSED unconditionally (the degraded task is already stage='done' — BR-7 — so
|
||||
# the ordinary active-task gate would not hold it) until an operator clears it
|
||||
# via POST /serial-gate/unfreeze. Append-only journal: an ACTIVE freeze for repo
|
||||
# R ⇔ a row with repo=R AND cleared_at IS NULL. Purely ADDITIVE (CREATE
|
||||
# TABLE/INDEX IF NOT EXISTS) -> idempotent, restart-safe on the live shared prod
|
||||
# DB (enduro-trails data untouched). See 08-data-requirements.md.
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS repo_freeze (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo TEXT NOT NULL,
|
||||
frozen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reason TEXT,
|
||||
work_item_id TEXT,
|
||||
cleared_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_freeze_active
|
||||
ON repo_freeze (repo, cleared_at);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -301,6 +348,68 @@ def set_tracker_message_id(task_id: int, message_id: int) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-087 (BR-G1): tracker_messages ledger — full accounting of every card mid
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_tracker_message(task_id: int, message_id: int) -> None:
|
||||
"""ORCH-087: record a freshly-created tracker card mid in the ledger.
|
||||
|
||||
Called ONLY after a successful send_telegram (new_mid is not None). INSERT OR
|
||||
IGNORE keeps it idempotent: a repeat mid (race / restart replay) does not
|
||||
duplicate the row or resurrect a deleted_at stamp.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tracker_messages (task_id, message_id) "
|
||||
"VALUES (?, ?)",
|
||||
(task_id, message_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_open_tracker_messages(task_id: int) -> list[int]:
|
||||
"""ORCH-087: all still-open (deleted_at IS NULL) card mids for a task.
|
||||
|
||||
These are the cards the next bump must clean up. Ordered oldest-first so the
|
||||
oldest orphans are deleted first. Never includes the rows already marked
|
||||
deleted.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT message_id FROM tracker_messages "
|
||||
"WHERE task_id=? AND deleted_at IS NULL ORDER BY message_id ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def mark_tracker_message_deleted(task_id: int, message_id: int) -> None:
|
||||
"""ORCH-087: stamp deleted_at on a card mid that is confirmed gone.
|
||||
|
||||
Called for mids that delete_telegram reported as gone (deleted now OR already
|
||||
gone / >48h per _DELETE_GONE_MARKERS) so they drop out of
|
||||
get_open_tracker_messages. Transient-delete mids are left untouched (NULL) for
|
||||
a retry on the next bump.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tracker_messages SET deleted_at=datetime('now') "
|
||||
"WHERE task_id=? AND message_id=? AND deleted_at IS NULL",
|
||||
(task_id, message_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def mark_brd_review_started(task_id: int) -> None:
|
||||
"""Stamp when BRD review (the human approve gate) started, if not already set.
|
||||
|
||||
@@ -499,6 +608,19 @@ def claim_next_job() -> dict | None:
|
||||
" WHERE d.task_id = jobs.task_id AND t.stage != 'done'"
|
||||
") "
|
||||
)
|
||||
# ORCH-088 (FR-1, ADR-001 D1): per-repo serial gate. An analyst-job of a NEW
|
||||
# task is NOT claimable while the same repo has another unfinished task OR is
|
||||
# frozen. The fragment is built in the serial_gate leaf (sanitised repo scope,
|
||||
# fail-OPEN on any build error so a transient fault never wedges the queue of
|
||||
# ALL projects — AC-8). Jobs of an already-active task (architect/.../deployer)
|
||||
# are unaffected — the gate keys on jobs.agent='analyst' only. Reads only the
|
||||
# local DB (offline-safe hot path, NFR-2).
|
||||
serial_gate = ""
|
||||
try:
|
||||
from . import serial_gate as _serial_gate
|
||||
serial_gate = _serial_gate.build_claim_clause()
|
||||
except Exception: # noqa: BLE001 - fail-OPEN: never wedge the claim
|
||||
serial_gate = ""
|
||||
conn = get_db()
|
||||
try:
|
||||
while True:
|
||||
@@ -506,6 +628,7 @@ def claim_next_job() -> dict | None:
|
||||
"SELECT id FROM jobs WHERE status='queued' "
|
||||
"AND (available_at IS NULL OR available_at <= datetime('now')) "
|
||||
f"{dep_gate}"
|
||||
f"{serial_gate}"
|
||||
"ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
|
||||
33
src/main.py
33
src/main.py
@@ -149,6 +149,7 @@ async def queue():
|
||||
from . import post_deploy
|
||||
from . import merge_gate
|
||||
from . import task_deps
|
||||
from . import serial_gate
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
@@ -161,5 +162,37 @@ async def queue():
|
||||
# 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(),
|
||||
# ORCH-088 (D9 / AC-10): per-repo serial-gate observability (read-only) —
|
||||
# active task, queued/waiting analyst-jobs, freeze state. Additive block.
|
||||
"serial_gate": serial_gate.snapshot(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/serial-gate/unfreeze")
|
||||
async def serial_gate_unfreeze(repo: str = ""):
|
||||
"""ORCH-088 (FR-5, ADR-001 D4): manually clear a per-repo rollback-freeze.
|
||||
|
||||
A freeze set by the post-deploy monitor (DEGRADED) keeps the serial gate CLOSED
|
||||
for the repo until an operator explicitly clears it here. Idempotent: clearing
|
||||
an already-clear repo reports ``cleared: 0``. The next queued analyst-job is then
|
||||
claimable on the next scheduler tick (no restart needed). Alternative manual path
|
||||
(documented in README): ``UPDATE repo_freeze SET cleared_at=datetime('now')
|
||||
WHERE repo=? AND cleared_at IS NULL``.
|
||||
"""
|
||||
from . import serial_gate
|
||||
if not repo or not repo.strip():
|
||||
return {"ok": False, "error": "missing 'repo'", "repo": repo, "cleared": 0}
|
||||
repo = repo.strip()
|
||||
cleared = serial_gate.clear_repo_freeze(repo)
|
||||
frozen = serial_gate.is_repo_frozen(repo)
|
||||
if cleared:
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
send_telegram(
|
||||
f"🔥 {repo}: пакет РАЗМОРОЖЕН вручную ({cleared} запис(ь/и) снято). "
|
||||
f"Следующая задача репо стартует на ближайшем цикле."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen}
|
||||
|
||||
@@ -587,6 +587,101 @@ def merge_verify_applies(repo: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
|
||||
"""Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists.
|
||||
|
||||
ORCH-082 (ADR-001 Р-1 / FR-1): the idempotent leaf-actor that closes the missing
|
||||
invariant "by merge-verify time the branch has an open code-PR". The pipeline used
|
||||
to create a PR ONLY on the developer path with a fresh worktree commit
|
||||
(``launcher._ensure_pr``), so a branch could reach the ``deploy -> done`` merge-verify
|
||||
under-gate with no open code-PR -> ``merge_pr`` returned ``"no open PR"`` -> a FALSE
|
||||
HOLD (the ORCH-074 incident). This actor creates/finds the code-PR ДО the
|
||||
deterministic ``merge_pr``; ORCH-073's SHA-in-main proof stays authoritative.
|
||||
|
||||
Algorithm (FR-1):
|
||||
1. ``GET …/pulls?state=open`` -> a PR with **``head.ref==branch`` AND
|
||||
``base.ref=="main"``**. The filter is **identical** to ``merge_pr``/ORCH-073
|
||||
FR-3 so both actors agree on exactly the same PR — an auto docs-PR
|
||||
(``base != main``) is NOT a code-PR (AC-6). Found -> ``("existed", "<number>")``.
|
||||
2. Otherwise ``POST …/pulls`` (``head=branch``, ``base=main``, auto title/body) ->
|
||||
``201`` -> ``("created", "<number>")``.
|
||||
3. Idempotency on a race: a ``POST`` that fails because the PR already exists
|
||||
(Gitea ``409``/``422``) -> a repeat ``GET`` (step 1) confirms the existing PR ->
|
||||
``("existed", …)``; no duplicate is created (AC-2 / FR-5).
|
||||
4. Any other HTTP/parse/network error -> ``("failed", "<reason>")``.
|
||||
|
||||
Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``).
|
||||
Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is
|
||||
NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
base = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}"
|
||||
timeout = settings.merge_pr_timeout_s
|
||||
|
||||
def _find_open_code_pr() -> int | None:
|
||||
"""GET open PRs; return the code-PR number (head==branch AND base==main)."""
|
||||
resp = httpx.get(
|
||||
f"{base}/pulls", params={"state": "open"}, headers=headers, timeout=timeout
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
for pr in resp.json() or []:
|
||||
if (
|
||||
pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
return pr.get("number")
|
||||
return None
|
||||
|
||||
# Step 1: an open code-PR already exists -> existed (no duplicate POST).
|
||||
existing = _find_open_code_pr()
|
||||
if existing is not None:
|
||||
logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing)
|
||||
return "existed", str(existing)
|
||||
|
||||
# Step 2: create the code-PR onto main.
|
||||
parts = branch.split("/")
|
||||
title = parts[-1] if parts else branch
|
||||
m = httpx.post(
|
||||
f"{base}/pulls",
|
||||
json={
|
||||
"title": f"feat: {title}",
|
||||
"head": branch,
|
||||
"base": "main",
|
||||
"body": f"Auto-created by orchestrator merge-verify for {branch}",
|
||||
},
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if m.status_code in (200, 201):
|
||||
number = (m.json() or {}).get("number")
|
||||
logger.info("ensure_open_pr: created PR #%s for %s/%s", number, repo, branch)
|
||||
return "created", str(number)
|
||||
|
||||
# Step 3: race / already-exists (409 conflict, 422 unprocessable) -> re-GET.
|
||||
if m.status_code in (409, 422):
|
||||
again = _find_open_code_pr()
|
||||
if again is not None:
|
||||
logger.info(
|
||||
"ensure_open_pr: %s/%s PR already existed on retry (#%s, HTTP %s)",
|
||||
repo, branch, again, m.status_code,
|
||||
)
|
||||
return "existed", str(again)
|
||||
|
||||
detail = (m.text or "").strip()[:200]
|
||||
logger.warning(
|
||||
"ensure_open_pr: create failed for %s/%s: HTTP %s %s",
|
||||
repo, branch, m.status_code, detail,
|
||||
)
|
||||
return "failed", f"create PR failed: HTTP {m.status_code}"
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract (AC-7)
|
||||
logger.warning("ensure_open_pr unexpected error for %s/%s: %s", repo, branch, e)
|
||||
return "failed", f"ensure_open_pr error: {e}"
|
||||
|
||||
|
||||
def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
"""Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API.
|
||||
|
||||
@@ -730,6 +825,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
|
||||
("ORCH-069", "qg0_title_max", "src/config.py"),
|
||||
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
|
||||
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
|
||||
("ORCH-082", "ensure_open_pr", "src/merge_gate.py"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ def send_telegram(text: str, disable_notification: bool = False):
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_notification": disable_notification,
|
||||
# ORCH-080: suppress the Plane link-preview banner that Telegram
|
||||
# would otherwise expand under every tracker card / notification.
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -170,6 +173,8 @@ def edit_telegram(message_id: int, text: str) -> str:
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
# ORCH-080: suppress the Plane link-preview banner (see send_telegram).
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -285,6 +290,46 @@ def _duration_seconds(started, finished):
|
||||
return max(int((b - a).total_seconds()), 0)
|
||||
|
||||
|
||||
def _capped_review_str(review_seconds) -> str:
|
||||
"""ORCH-087 (BR-G5): human BRD-review duration, capped to drop anomalous stalls.
|
||||
|
||||
Returns '0м' when there was no review window. When the review exceeds
|
||||
``tracker_brd_review_cap_s`` (default 2h; <=0 disables the cap) the capped value
|
||||
is shown with a leading '~' to signal the real value was longer — an open
|
||||
brd_review clock from a desync (In Review -> Backlog) rather than genuine human
|
||||
time (ORCH-087: 392m). Never raises.
|
||||
"""
|
||||
try:
|
||||
if not review_seconds:
|
||||
return "0м"
|
||||
secs = int(review_seconds)
|
||||
try:
|
||||
cap = int(getattr(_get_settings(), "tracker_brd_review_cap_s", 0) or 0)
|
||||
except Exception:
|
||||
cap = 0
|
||||
if cap > 0 and secs > cap:
|
||||
return f"~{_fmt_minutes(cap)}"
|
||||
return _fmt_minutes(secs)
|
||||
except Exception:
|
||||
return _fmt_minutes(review_seconds) if review_seconds else "0м"
|
||||
|
||||
|
||||
def _run_effort(run) -> str:
|
||||
"""ORCH-087 (BR-EFF): the effort tag for a stage line. Never raises -> ''.
|
||||
|
||||
Returns the stamped agent_runs.effort (the REAL --effort sent at launch). NULL
|
||||
/ empty (historical row predating the column, or a launch with no --effort
|
||||
flag) -> '' so the caller omits the effort suffix (the documented default,
|
||||
AC-E.4). New runs are stamped in launcher._spawn, so going forward every stage
|
||||
line carries its resolved effort (developer xhigh, tester/deployer medium, …).
|
||||
"""
|
||||
try:
|
||||
effort = _row_get(run, "effort")
|
||||
return str(effort) if effort else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def render_task_tracker(task_id: int) -> str:
|
||||
"""Build the full live-tracker text for a task from the DB (stateless render).
|
||||
|
||||
@@ -316,7 +361,8 @@ def render_task_tracker(task_id: int) -> str:
|
||||
return f"task-{task_id}"
|
||||
runs = conn.execute(
|
||||
"SELECT agent, started_at, finished_at, exit_code, input_tokens, "
|
||||
"output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, model "
|
||||
"output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, "
|
||||
"model, effort "
|
||||
"FROM agent_runs WHERE task_id=? ORDER BY id ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
@@ -408,9 +454,15 @@ def render_task_tracker(task_id: int) -> str:
|
||||
dur = _fmt_minutes(_duration_seconds(run["started_at"], run["finished_at"]))
|
||||
model = short_model_name(run["model"])
|
||||
model_suffix = f" \u00b7 {model}" if model else ""
|
||||
# ORCH-087 (BR-EFF): render the resolved --effort next to the model
|
||||
# ("\u00b7 opus-4-8 \u00b7 xhigh"). Stamped at launch in agent_runs.effort; empty /
|
||||
# missing -> suffix omitted (like the model suffix). Historical rows with
|
||||
# NULL effort fall back to the config-resolved effort for the agent.
|
||||
effort = _run_effort(run)
|
||||
effort_suffix = f" \u00b7 {effort}" if effort else ""
|
||||
return (
|
||||
f"\u2705 {label:<13} {dur} \u00b7 "
|
||||
f"{in_tok}\u2193/{out_tok}\u2191 \u00b7 {cost}{model_suffix}"
|
||||
f"{in_tok}\u2193/{out_tok}\u2191 \u00b7 {cost}{model_suffix}{effort_suffix}"
|
||||
)
|
||||
|
||||
# BRD review line: between Analysis and Architecture, only once Analysis has
|
||||
@@ -485,11 +537,17 @@ def render_task_tracker(task_id: int) -> str:
|
||||
if done:
|
||||
wall = _duration_seconds(task["created_at"], task["updated_at"])
|
||||
wall_str = _fmt_minutes(wall) if wall is not None else "?"
|
||||
review_str = _fmt_minutes(review_seconds) if review_seconds else "0м"
|
||||
review_str = _capped_review_str(review_seconds)
|
||||
# ORCH-087 (BR-G5): three INDEPENDENT, explicitly-labelled metrics. None is
|
||||
# presented as the sum of the others \u2014 queue/wait pauses are not logged, so
|
||||
# wall != agents + review; the old "\u0412\u0441\u0435\u0433\u043e {wall}" read like a (wrong) sum.
|
||||
# \u0410\u0433\u0435\u043d\u0442\u044b = sum(agent_runs) (precise main metric, T-1)
|
||||
# \u0442\u0432\u043e\u0451 = human BRD-review, capped to drop anomalous stalls (T-2)
|
||||
# \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c = wall-clock incl. queue/wait, NOT work time (T-3)
|
||||
lines.append(
|
||||
f"\u23f1\ufe0f \u0412\u0441\u0435\u0433\u043e {wall_str} \u00b7 "
|
||||
f"\u0430\u0433\u0435\u043d\u0442\u044b {_fmt_minutes(agent_seconds)} \u00b7 "
|
||||
f"\u0442\u0432\u043e\u0451 {review_str}"
|
||||
f"\u23f1\ufe0f \u0410\u0433\u0435\u043d\u0442\u044b {_fmt_minutes(agent_seconds)} \u00b7 "
|
||||
f"\u0442\u0432\u043e\u0451 {review_str} \u00b7 "
|
||||
f"\u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c {wall_str}"
|
||||
)
|
||||
link = _done_link(task_id, task["work_item_id"])
|
||||
if link:
|
||||
@@ -563,21 +621,53 @@ def update_task_tracker(task_id: int):
|
||||
only the dedicated alert helpers ping.
|
||||
"""
|
||||
try:
|
||||
from .db import get_tracker_message_id, set_tracker_message_id
|
||||
from .db import (
|
||||
get_tracker_message_id, set_tracker_message_id,
|
||||
get_open_tracker_messages, add_tracker_message,
|
||||
mark_tracker_message_deleted,
|
||||
)
|
||||
text = render_task_tracker(task_id)
|
||||
mode = (_get_settings().tracker_mode or "edit").strip().lower()
|
||||
mid = get_tracker_message_id(task_id)
|
||||
|
||||
if mode == "bump":
|
||||
# bump: one card, always at the bottom (delete + send + repoint).
|
||||
# ORCH-087 (BR-G1): clean up ALL still-open cards of this task, not
|
||||
# only the last (scalar) mid. The ledger is the authoritative set of
|
||||
# every card ever created; any reference lost by the scalar (race /
|
||||
# delete-fail+send-ok / restart) is still tracked here and reaped now.
|
||||
open_mids = set()
|
||||
try:
|
||||
open_mids.update(get_open_tracker_messages(task_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"update_task_tracker({task_id}): ledger read failed: {e}")
|
||||
if mid is not None:
|
||||
# Scalar pointer is part of the live set (e.g. a card sent before
|
||||
# the ledger existed); union avoids missing it.
|
||||
open_mids.add(mid)
|
||||
for old_mid in open_mids:
|
||||
# best-effort; result does NOT gate the send (BR-6).
|
||||
delete_telegram(mid)
|
||||
if delete_telegram(old_mid):
|
||||
# gone (deleted now OR already gone / >48h) -> drop from ledger.
|
||||
try:
|
||||
mark_tracker_message_deleted(task_id, old_mid)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"update_task_tracker({task_id}): mark-deleted failed: {e}"
|
||||
)
|
||||
# transient False -> leave open in the ledger for a retry next bump.
|
||||
new_mid = send_telegram(text, disable_notification=True)
|
||||
if new_mid is not None:
|
||||
# R-3 / BR-6: only record the new card on a successful send.
|
||||
try:
|
||||
add_tracker_message(task_id, new_mid)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"update_task_tracker({task_id}): ledger insert failed: {e}"
|
||||
)
|
||||
set_tracker_message_id(task_id, new_mid)
|
||||
# send returned None (no creds / transient) -> leave mid untouched;
|
||||
# no duplicate within this call, redraws on the next transition.
|
||||
# send returned None (no creds / transient) -> leave mid/ledger
|
||||
# untouched; no duplicate within this call, redraws next transition.
|
||||
return
|
||||
|
||||
# mode == "edit" (DEFAULT): existing behaviour, unchanged.
|
||||
@@ -869,6 +959,11 @@ _LIVE_BRANCH_LABELS = {
|
||||
"blocked": "Blocked",
|
||||
"rejected": "Rejected",
|
||||
"cancelled": "Cancelled",
|
||||
# ORCH-087 (G3, ADR-001 Р-4): close the deploy cycle on the card. The
|
||||
# confirm_deploy logical key already exists in plane_sync (ORCH-059); drawn as
|
||||
# a real, dedicated status (no base-alias) when its UUID is live in Plane so the
|
||||
# card can show Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done.
|
||||
"confirm_deploy": "⏳ Confirm Deploy — подтвердите прод-деплой",
|
||||
"deploying": "Deploying",
|
||||
"monitoring": "Monitoring after Deploy",
|
||||
}
|
||||
|
||||
@@ -73,6 +73,11 @@ from . import task_deps
|
||||
|
||||
logger = logging.getLogger("orchestrator.reconciler")
|
||||
|
||||
# ORCH-086 (D3): sentinel distinguishing "caller did not pass a pre-resolved
|
||||
# state_uuid" (Guard 2 self-resolves, backward-compatible 1-arg call) from an
|
||||
# explicit ``None`` (Plane unreachable -> conservative skip).
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _parse_grace_overrides(raw: str) -> dict[str, int]:
|
||||
"""Parse ``reconcile_grace_overrides_json`` into {stage: seconds}.
|
||||
@@ -183,6 +188,14 @@ class Reconciler:
|
||||
# AC-16: analysis is a human gate -> owned by F-2, never F-1.
|
||||
if stage == "analysis":
|
||||
return
|
||||
# ORCH-086 D2 (DB-side terminal drift): ``get_active_tasks_for_reconcile``
|
||||
# filters ``stage != 'done'`` but NOT ``cancelled``. A task already
|
||||
# terminal in the orchestrator DB is fully in sync by definition -> skip
|
||||
# before any gate/network work, mirroring the F-2 terminal-skip counter
|
||||
# (single semantics with ``_reconcile_plane_issue``). Local, no network.
|
||||
if stage in ("done", "cancelled"):
|
||||
self.skipped_terminal_total += 1
|
||||
return
|
||||
# created / done have no gate to evaluate.
|
||||
if get_qg_for_stage(stage) is None:
|
||||
return
|
||||
@@ -201,9 +214,25 @@ class Reconciler:
|
||||
# Deterministic, local SQL, no network — and checked FIRST (cheapest).
|
||||
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
|
||||
return
|
||||
# ORCH-086 D1: single networked resolve per task per tick, AFTER the cheap
|
||||
# local guards (so busy/young/escalated tasks never hit Plane). Feeds the
|
||||
# Plane-side terminal-skip (D2), Guard 2 (D3) and the state_uuid handed to
|
||||
# _note_unblock (D4) — no duplicate fetch.
|
||||
states, groups, state_uuid = self._resolve_issue_status(task)
|
||||
# ORCH-086 D2 (Plane-side terminal-skip), UNCONDITIONAL (not gated by
|
||||
# reconcile_skip_blocked_enabled, which gates ONLY Guard 2). A task whose
|
||||
# Plane status is terminal (group completed/cancelled, or the logical
|
||||
# done/cancelled fallback) is fully in sync -> never a real unblock.
|
||||
# Runs BEFORE Guard 2 so terminal tasks correctly bump skipped_terminal_total
|
||||
# instead of being swallowed by Guard 2's conservative path. Closes the F-1
|
||||
# gap of ORCH-068 (which only covered F-2); fixes the spurious
|
||||
# "ET-002 ... разблокирована" notification.
|
||||
if self._is_terminal_state(state_uuid, states, groups):
|
||||
self.skipped_terminal_total += 1
|
||||
return
|
||||
# ORCH-060 Guard 2: respect an explicit human gate (Blocked / Needs Input).
|
||||
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
|
||||
if self._is_blocked_or_needs_input(task):
|
||||
# Reuses the D1 resolve (ORCH-086 D3) so the tick makes a single fetch.
|
||||
if self._is_blocked_or_needs_input(task, states, state_uuid):
|
||||
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
|
||||
@@ -225,9 +254,48 @@ class Reconciler:
|
||||
task.get("branch") or "",
|
||||
)
|
||||
if result is not None and getattr(result, "advanced", False):
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
# ORCH-086 D4: pass state_uuid so the in-memory dedup guard covers F-1
|
||||
# too (a repeat tick for the same issue+state is suppressed; survives
|
||||
# the "first pass after restart" symptom together with the D2 skip).
|
||||
self._note_unblock(
|
||||
task.get("work_item_id") or str(task_id), stage, state_uuid
|
||||
)
|
||||
|
||||
def _is_blocked_or_needs_input(self, task: dict) -> bool:
|
||||
def _resolve_issue_status(
|
||||
self, task: dict
|
||||
) -> tuple[dict, dict, str | None]:
|
||||
"""ORCH-086 D1: one networked resolve per task per tick.
|
||||
|
||||
Returns ``(states, groups, current_state_uuid)``. A single
|
||||
``fetch_issue_state`` plus the cached (ORCH-068 TTL)
|
||||
``get_project_states`` / ``get_project_state_groups``. The result feeds
|
||||
the terminal-skip (D2), Guard 2 (D3) and the ``state_uuid`` handed to
|
||||
``_note_unblock`` (D4), so the tick never fetches the same issue twice.
|
||||
|
||||
**never-raise.** On any failure / unresolved project / missing state ->
|
||||
``({} or states, {} or groups, None)`` so callers apply their
|
||||
conservative fallback (terminal-skip = not terminal; Guard 2 = skip).
|
||||
"""
|
||||
try:
|
||||
proj = projects.get_project_by_repo(task.get("repo") or "")
|
||||
if proj is None:
|
||||
return {}, {}, None
|
||||
pid = proj.plane_project_id
|
||||
states = get_project_states(pid)
|
||||
groups = get_project_state_groups(pid)
|
||||
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
|
||||
state_uuid = fetch_issue_state(issue_id, pid)
|
||||
return states or {}, groups or {}, state_uuid
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(
|
||||
f"reconciler D1: status resolve failed for task "
|
||||
f"{task.get('id')}, treating as unresolved: {e}"
|
||||
)
|
||||
return {}, {}, None
|
||||
|
||||
def _is_blocked_or_needs_input(
|
||||
self, task: dict, states: dict | None = None, state_uuid=_UNSET
|
||||
) -> bool:
|
||||
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
|
||||
an active orchestrator wait that F-1 must not "revive"?
|
||||
|
||||
@@ -251,19 +319,22 @@ class Reconciler:
|
||||
human-gated task re-introduces the bounce we are trying to kill. The
|
||||
sub-flag ``reconcile_skip_blocked_enabled`` disables ONLY this networked
|
||||
guard (escape hatch for a Plane outage); Guard 1 stays active.
|
||||
|
||||
**ORCH-086 D3:** the production caller (``_reconcile_gate_task``) passes
|
||||
the already-resolved ``(states, state_uuid)`` from the single D1 fetch, so
|
||||
the tick does not hit Plane twice. When ``state_uuid`` is left ``_UNSET``
|
||||
(direct/legacy 1-arg call) Guard 2 self-resolves via ``_resolve_issue_status``
|
||||
— behaviour identical to the pre-ORCH-086 code.
|
||||
"""
|
||||
if not settings.reconcile_skip_blocked_enabled:
|
||||
return False
|
||||
try:
|
||||
proj = projects.get_project_by_repo(task.get("repo") or "")
|
||||
if proj is None:
|
||||
return True # cannot resolve the project -> conservative skip
|
||||
pid = proj.plane_project_id
|
||||
states = get_project_states(pid)
|
||||
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
|
||||
cur = fetch_issue_state(issue_id, pid)
|
||||
if cur is None:
|
||||
return True # Plane unreachable / no state -> conservative skip
|
||||
if state_uuid is _UNSET:
|
||||
# Backward-compatible self-resolve (direct callers / tests).
|
||||
states, _groups, state_uuid = self._resolve_issue_status(task)
|
||||
if not states or state_uuid is None:
|
||||
return True # unresolved project / Plane unreachable -> conservative skip
|
||||
cur = state_uuid
|
||||
# ORCH-066 BR-13: active orchestrator waits, minus base working
|
||||
# statuses so aliased (enduro) keys never widen the skip-set.
|
||||
base_working = {
|
||||
|
||||
404
src/serial_gate.py
Normal file
404
src/serial_gate.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""ORCH-088 (Этап 1, serial e2e): per-repo serial gate + durable rollback-freeze.
|
||||
|
||||
Leaf module — pure, unit-testable logic over the existing ``tasks`` / ``jobs``
|
||||
tables and the additive ``repo_freeze`` table (see src/db.py /
|
||||
08-data-requirements.md). Mirrors the leaf pattern of ``src/task_deps.py`` /
|
||||
``src/post_deploy.py``: imports only ``db`` + ``config`` (and lazily
|
||||
``projects`` for the snapshot), never ``stage_engine`` / ``launcher``.
|
||||
|
||||
What it enforces (ADR-001):
|
||||
* A NEW task's analyst-job does NOT enter analysis (no branch cut, no analyst
|
||||
agent) while the same repo has ANOTHER unfinished task (``tasks.stage !=
|
||||
'done'``) OR the repo is frozen. The gate is a SQL fragment spliced into
|
||||
``db.claim_next_job`` (offline hot path) — ``build_claim_clause``.
|
||||
* After a post-deploy ``DEGRADED`` verdict the repo is frozen
|
||||
(``set_repo_freeze``); the gate stays CLOSED until an operator clears it
|
||||
(``clear_repo_freeze``). The degraded task is already ``stage='done'`` (BR-7)
|
||||
so freeze is a SEPARATE durable signal, not derived from a stage.
|
||||
|
||||
never-raise contract (self-hosting safety): every public function degrades
|
||||
conservatively and NEVER propagates into the worker / webhook / stage engine.
|
||||
Two deliberately different failure directions (ADR-001 D10, NFR-1):
|
||||
* hot-claim clause build -> fail-OPEN ("" fragment): a transient DB/build error
|
||||
must not wedge the queue of ALL projects (AC-8).
|
||||
* freeze decision (``is_repo_frozen``) -> fail-CLOSED (``True``): when we cannot
|
||||
confirm the ABSENCE of a freeze we keep the gate closed for prod safety (AC-9).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from . import db
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.serial_gate")
|
||||
|
||||
# Repo tokens embedded into the claim SQL ``IN (...)`` list must match this — a
|
||||
# guard against a broken/injected ORCH_SERIAL_GATE_REPOS CSV (R-6). The CSV is an
|
||||
# operator config (not user input), but the guard is mandatory; an invalid token
|
||||
# is silently dropped.
|
||||
_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditionality (mirrors post_deploy_applies / _merge_gate_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _scope_repos() -> set[str]:
|
||||
"""Sanitised set of in-scope repo tokens from ``serial_gate_repos`` (CSV).
|
||||
|
||||
Empty/blank CSV -> empty set, meaning "apply to ALL repos" (D5). Invalid
|
||||
tokens (regex miss) are dropped. Never raises.
|
||||
"""
|
||||
try:
|
||||
raw = (settings.serial_gate_repos or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return set()
|
||||
if not raw:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for tok in raw.split(","):
|
||||
t = tok.strip()
|
||||
if t and _REPO_TOKEN.match(t):
|
||||
out.add(t)
|
||||
elif t:
|
||||
logger.warning("serial_gate: dropping invalid repo token %r from CSV", t)
|
||||
return out
|
||||
|
||||
|
||||
def serial_gate_applies(repo: str) -> bool:
|
||||
"""Whether the serial gate is REAL for this repo (D5 / AC-7).
|
||||
|
||||
* ``serial_gate_enabled=False`` -> always False (kill-switch; claim and
|
||||
start_pipeline are 1:1 as before ORCH-088).
|
||||
* ``serial_gate_repos`` (CSV) non-empty -> real only for listed repos.
|
||||
* empty CSV -> real for ALL repos (serial e2e + anti-stale-base help every
|
||||
repo, unlike the self-hosting-only ORCH-35/43/58 gates).
|
||||
Never raises -> False on error (degrade to "gate inert", the safe-for-flow
|
||||
default that matches the kill-switch off behaviour).
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "serial_gate_enabled", False):
|
||||
return False
|
||||
scope = _scope_repos()
|
||||
if scope:
|
||||
return (repo or "").strip() in scope
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("serial_gate_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def _freeze_layer_enabled() -> bool:
|
||||
"""Whether the FR-5 freeze layer is active (independent tumbler, D7)."""
|
||||
try:
|
||||
return bool(getattr(settings, "serial_gate_freeze_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read helpers (active task + freeze) — only the local DB
|
||||
# ---------------------------------------------------------------------------
|
||||
def repo_has_active_task(repo: str, exclude_task_id: int | None = None) -> bool:
|
||||
"""True iff repo has a task with ``stage != 'done'`` (excluding one task).
|
||||
|
||||
``exclude_task_id`` is the task being evaluated (a new/rework task must not
|
||||
count ITSELF as the active task that blocks it — R-7). Observability/Python
|
||||
mirror of the SQL gate; never raises -> False on error.
|
||||
"""
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
if exclude_task_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done' LIMIT 1",
|
||||
(repo, exclude_task_id),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE repo=? AND stage != 'done' LIMIT 1",
|
||||
(repo,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("repo_has_active_task error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def _active_freeze_row(repo: str) -> dict | None:
|
||||
"""Most-recent active (``cleared_at IS NULL``) freeze row for repo, or None.
|
||||
|
||||
Raises on a real DB error (the caller decides fail-open vs fail-closed) — this
|
||||
private helper does NOT swallow so ``is_repo_frozen`` can fail CLOSED.
|
||||
"""
|
||||
conn = db.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT repo, frozen_at, reason, work_item_id FROM repo_freeze "
|
||||
"WHERE repo=? AND cleared_at IS NULL ORDER BY id DESC LIMIT 1",
|
||||
(repo,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_repo_frozen(repo: str) -> bool:
|
||||
"""True iff repo currently has an active freeze (FR-5).
|
||||
|
||||
fail-CLOSED (AC-9): when the freeze layer is enabled and we CANNOT confirm the
|
||||
absence of a freeze (DB error), return True — keep the gate closed for prod
|
||||
safety. When the freeze layer is disabled the repo is never considered frozen.
|
||||
"""
|
||||
if not _freeze_layer_enabled():
|
||||
return False
|
||||
try:
|
||||
return _active_freeze_row(repo) is not None
|
||||
except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt (AC-9)
|
||||
logger.warning("is_repo_frozen error for %s -> fail-CLOSED (frozen): %s", repo, e)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Freeze mutators (FR-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
def set_repo_freeze(repo: str, reason: str = "", work_item_id: str | None = None) -> bool:
|
||||
"""Insert a durable freeze row for repo (no-op when the freeze layer is off).
|
||||
|
||||
Append-only: a repeated DEGRADED while already frozen simply adds another row
|
||||
(history); ``is_repo_frozen``'s EXISTS is idempotent. Returns True iff a row
|
||||
was inserted. never-raise -> False on error (a freeze write failure must not
|
||||
crash the post-deploy monitor tick).
|
||||
"""
|
||||
if not _freeze_layer_enabled():
|
||||
logger.info("set_repo_freeze: freeze layer disabled, skipping for %s", repo)
|
||||
return False
|
||||
if not repo:
|
||||
return False
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO repo_freeze (repo, reason, work_item_id) VALUES (?, ?, ?)",
|
||||
(repo, reason or None, work_item_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
logger.warning(
|
||||
"serial_gate: repo %s FROZEN (reason=%r, work_item=%s) — next task will "
|
||||
"NOT start until manual unfreeze", repo, reason, work_item_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.error("set_repo_freeze error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def clear_repo_freeze(repo: str) -> int:
|
||||
"""Clear ALL active freeze rows for repo (operator unfreeze, D4).
|
||||
|
||||
Sets ``cleared_at=now`` on every row with ``cleared_at IS NULL``. Idempotent
|
||||
(a repeat clears 0 rows). Returns the number of rows cleared. never-raise -> 0
|
||||
on error.
|
||||
"""
|
||||
if not repo:
|
||||
return 0
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE repo_freeze SET cleared_at=datetime('now') "
|
||||
"WHERE repo=? AND cleared_at IS NULL",
|
||||
(repo,),
|
||||
)
|
||||
conn.commit()
|
||||
n = cur.rowcount or 0
|
||||
finally:
|
||||
conn.close()
|
||||
if n:
|
||||
logger.warning("serial_gate: repo %s UNFROZEN (%d row(s) cleared)", repo, n)
|
||||
return n
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.error("clear_repo_freeze error for %s: %s", repo, e)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hot-claim SQL fragment (fail-OPEN) — ADR-001 D1
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_claim_clause() -> str:
|
||||
"""Build the ``AND NOT (...)`` fragment spliced into ``claim_next_job``.
|
||||
|
||||
Blocks an analyst-job whose repo (a) has an EARLIER-queued unfinished task or
|
||||
(b) is frozen. Only ``jobs.agent='analyst'`` is gated — jobs of an
|
||||
already-active task pass freely (else the single active task could never
|
||||
advance).
|
||||
|
||||
Ordering term — ``t2.id < jobs.task_id`` (FIFO, ADR-001 D1 / FR-2): a task is
|
||||
blocked only by EARLIER tasks (lower ``tasks.id``) that are not yet done. This
|
||||
is the FIFO refinement of the ADR's pseudo-SQL ``t2.id != jobs.task_id``: with
|
||||
``!=`` a BATCH of fresh tasks all sitting in ``analysis`` would mutually block
|
||||
(each is "another unfinished task" for the others) -> the whole serial queue
|
||||
deadlocks, contradicting FR-2 ("строго по одной, FIFO по jobs.id"). ``<`` admits
|
||||
exactly the oldest unfinished task and serialises the rest behind it, while
|
||||
still never self-blocking a new/rework analyst-job on its OWN row (R-7) and
|
||||
keeping AC-1 (a newer task is held by the older active one) intact.
|
||||
|
||||
Repo scope: empty CSV -> no repo filter (all repos); non-empty CSV -> ``AND
|
||||
jobs.repo IN ('a','b')`` with sanitised tokens (R-6).
|
||||
|
||||
fail-OPEN (AC-8): kill-switch off OR any build error -> ``""`` (claim behaves
|
||||
exactly as before ORCH-088). The trailing space keeps the spliced SQL valid.
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "serial_gate_enabled", False):
|
||||
return ""
|
||||
scope = _scope_repos()
|
||||
if scope:
|
||||
# All tokens already passed the _REPO_TOKEN regex -> safe to embed.
|
||||
repo_in = ", ".join(f"'{t}'" for t in sorted(scope))
|
||||
repo_scope = f"AND jobs.repo IN ({repo_in}) "
|
||||
else:
|
||||
repo_scope = ""
|
||||
active_clause = (
|
||||
"EXISTS (SELECT 1 FROM tasks t2 "
|
||||
"WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id "
|
||||
"AND t2.stage != 'done') "
|
||||
)
|
||||
if _freeze_layer_enabled():
|
||||
freeze_clause = (
|
||||
"OR EXISTS (SELECT 1 FROM repo_freeze f "
|
||||
"WHERE f.repo = jobs.repo AND f.cleared_at IS NULL) "
|
||||
)
|
||||
else:
|
||||
freeze_clause = ""
|
||||
return (
|
||||
"AND NOT ( jobs.agent = 'analyst' "
|
||||
f"{repo_scope}"
|
||||
f"AND ( {active_clause}{freeze_clause}) "
|
||||
") "
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - fail-OPEN: never wedge the queue
|
||||
logger.warning("build_claim_clause error -> fail-OPEN (no gate): %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability snapshot for GET /queue (D9 / AC-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _known_repos() -> list[str]:
|
||||
"""Registered repo names (best-effort) plus any repo with live gate state."""
|
||||
repos: set[str] = set()
|
||||
try:
|
||||
from . import projects
|
||||
for p in projects.PROJECTS:
|
||||
if getattr(p, "repo", None):
|
||||
repos.add(p.repo)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# Also surface repos that have an active freeze or a queued analyst-job even if
|
||||
# they are not in the static registry (defensive — never hide a frozen repo).
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
for (r,) in conn.execute(
|
||||
"SELECT DISTINCT repo FROM repo_freeze WHERE cleared_at IS NULL"
|
||||
).fetchall():
|
||||
if r:
|
||||
repos.add(r)
|
||||
for (r,) in conn.execute(
|
||||
"SELECT DISTINCT repo FROM jobs WHERE status='queued' AND agent='analyst'"
|
||||
).fetchall():
|
||||
if r:
|
||||
repos.add(r)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return sorted(repos)
|
||||
|
||||
|
||||
def _per_repo_snapshot(repo: str) -> dict:
|
||||
"""Per-repo gate state for the /queue snapshot (never raises here)."""
|
||||
active_task = None
|
||||
waiting: list[dict] = []
|
||||
try:
|
||||
conn = db.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT work_item_id, stage FROM tasks "
|
||||
"WHERE repo=? AND stage != 'done' ORDER BY id LIMIT 1",
|
||||
(repo,),
|
||||
).fetchone()
|
||||
if row:
|
||||
active_task = {"work_item_id": row["work_item_id"], "stage": row["stage"]}
|
||||
for j in conn.execute(
|
||||
"SELECT j.id AS job_id, t.work_item_id AS work_item_id, t.stage AS stage "
|
||||
"FROM jobs j LEFT JOIN tasks t ON t.id = j.task_id "
|
||||
"WHERE j.repo=? AND j.status='queued' AND j.agent='analyst' "
|
||||
"ORDER BY j.id",
|
||||
(repo,),
|
||||
).fetchall():
|
||||
waiting.append({
|
||||
"job_id": j["job_id"],
|
||||
"work_item_id": j["work_item_id"],
|
||||
"stage": j["stage"],
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("serial_gate per-repo snapshot error for %s: %s", repo, e)
|
||||
frozen = is_repo_frozen(repo)
|
||||
frozen_reason = None
|
||||
frozen_at = None
|
||||
if frozen:
|
||||
try:
|
||||
fr = _active_freeze_row(repo)
|
||||
if fr:
|
||||
frozen_reason = fr.get("reason")
|
||||
frozen_at = fr.get("frozen_at")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {
|
||||
"active_task": active_task,
|
||||
"waiting": waiting,
|
||||
"frozen": frozen,
|
||||
"frozen_reason": frozen_reason,
|
||||
"frozen_at": frozen_at,
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Read-only serial-gate summary for GET /queue (D9 / AC-10).
|
||||
|
||||
Additive block; existing /queue keys are untouched. never-raise: any error ->
|
||||
a minimal dict with the flags and empty per-repo data.
|
||||
"""
|
||||
try:
|
||||
enabled = bool(getattr(settings, "serial_gate_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
enabled = False
|
||||
try:
|
||||
repos_cfg = getattr(settings, "serial_gate_repos", "") or ""
|
||||
except Exception: # noqa: BLE001
|
||||
repos_cfg = ""
|
||||
try:
|
||||
per_repo = {r: _per_repo_snapshot(r) for r in _known_repos()}
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"freeze_enabled": _freeze_layer_enabled(),
|
||||
"repos": repos_cfg,
|
||||
"per_repo": per_repo,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> minimal dict
|
||||
logger.warning("serial_gate snapshot error: %s", e)
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"freeze_enabled": False,
|
||||
"repos": repos_cfg,
|
||||
"per_repo": {},
|
||||
}
|
||||
@@ -1321,6 +1321,52 @@ def _hold_main_regressed(
|
||||
return True
|
||||
|
||||
|
||||
def _hold_pr_create_failed(
|
||||
task_id, repo, work_item_id, branch, reason: str, result: AdvanceResult
|
||||
) -> bool:
|
||||
"""HOLD the task because the open code-PR could not be ensured (ORCH-082 Р-3).
|
||||
|
||||
FR-2/FR-4 (AC-5/AC-7): ``ensure_open_pr`` returned ``"failed"`` (Gitea unreachable /
|
||||
HTTP error) — there is no open code-PR and one could not be created. Symmetric to the
|
||||
not-merged / regressed HOLD: task stays on ``deploy`` (NOT done), NO rollback to
|
||||
development, ALERT-only (Telegram + Plane ``set_issue_blocked`` + comment). The HOLD
|
||||
text MUST be distinguishable from the not-merged HOLD so the operator sees the cause is
|
||||
"could not CREATE the PR" (infra), not "could not MERGE an existing one". Returns
|
||||
``True`` (INTERVENED). Never breaks the HOLD on a notify error; ``failed`` is a
|
||||
structured outcome, not a propagated exception (INV-1).
|
||||
"""
|
||||
merge_gate.note_not_merged_alert(work_item_id) # reuse the counter-notifier.
|
||||
msg = (
|
||||
f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, "
|
||||
f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача "
|
||||
f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR."
|
||||
)
|
||||
logger.warning(f"Task {task_id}: {msg}")
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_blocked(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: set_issue_blocked failed: {e}")
|
||||
try:
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
"\U0001f6a8 PR создать не удалось: " + reason + ". Открытый код-PR "
|
||||
"отсутствует — задача удержана на `deploy` (НЕ done). Проверьте "
|
||||
"доступность Gitea / создайте PR вручную и повторите approve.",
|
||||
author="deployer",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: plane pr-create-failed comment failed: {e}")
|
||||
try:
|
||||
send_telegram(f"\U0001f6a8 {msg}")
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: pr-create-failed telegram failed: {e}")
|
||||
result.alerted = True
|
||||
result.note = "pr-create-failed-hold"
|
||||
result.advanced = False
|
||||
return True
|
||||
|
||||
|
||||
def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceResult) -> bool:
|
||||
"""ORCH-071 merge-verify under-gate on the `deploy -> done` edge.
|
||||
|
||||
@@ -1353,6 +1399,24 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
|
||||
from . import image_freshness
|
||||
sha = image_freshness.validated_revision(repo, branch)
|
||||
|
||||
# ORCH-082 (Р-2 / FR-2): guarantee an open code-PR (head==branch, base==main)
|
||||
# BEFORE the deterministic merge_pr. The pipeline never guaranteed the branch
|
||||
# had one at merge time (PRs are created only on the developer path with a fresh
|
||||
# commit) -> a PR-less branch hit merge_pr "no open PR" -> a FALSE HOLD (ORCH-074).
|
||||
# `created`/`existed` -> proceed unchanged; `failed` -> honest HOLD with a
|
||||
# distinguishable text (NOT the not-merged HOLD). ORCH-073's SHA-in-main proof
|
||||
# below is untouched and stays authoritative. Kill-switch off -> 1:1 prior path.
|
||||
if settings.merge_verify_autocreate_pr_enabled:
|
||||
pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(
|
||||
f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})"
|
||||
)
|
||||
if pr_status == "failed":
|
||||
return _hold_pr_create_failed(
|
||||
task_id, repo, work_item_id, branch, pr_detail, result
|
||||
)
|
||||
# "created" | "existed" -> proceed normally to merge_pr.
|
||||
|
||||
# Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9).
|
||||
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
|
||||
logger.info(
|
||||
@@ -1644,6 +1708,25 @@ def run_post_deploy_monitor(job: dict):
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}")
|
||||
|
||||
# ORCH-088 (FR-5, ADR-001 D3): durable per-repo rollback-freeze. The degraded
|
||||
# task is already stage='done' (BR-7), so the ordinary active-task gate would
|
||||
# NOT hold the next task — we need a separate durable signal. Freeze the repo so
|
||||
# the serial gate stays CLOSED until an operator clears it (POST
|
||||
# /serial-gate/unfreeze). never-raise (set_repo_freeze swallows its own errors);
|
||||
# the freeze is a PASSIVE start-block, it does NOT touch the prod container (NFR-6).
|
||||
try:
|
||||
from . import serial_gate
|
||||
reason = f"post-deploy DEGRADED ({checks_failed}/{checks_total}) action={action_taken}"
|
||||
if serial_gate.set_repo_freeze(repo, reason, work_item_id):
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"🧊 {repo}: пакет ЗАМОРОЖЕН после пост-деплой DEGRADED "
|
||||
f"({work_item_id}). Следующая задача репо НЕ стартует до ручного "
|
||||
f"снятия: POST /serial-gate/unfreeze?repo={repo}.",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set_repo_freeze failed for {repo}: {e}")
|
||||
|
||||
post_deploy.write_post_deploy_log(
|
||||
repo, work_item_id, branch, post_deploy.DEGRADED, action_taken,
|
||||
settings.post_deploy_window_s, checks_total, checks_failed,
|
||||
|
||||
@@ -573,20 +573,36 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
return
|
||||
task_id = task_row["id"]
|
||||
|
||||
# Create branch in Gitea
|
||||
try:
|
||||
await _create_gitea_branch(repo, branch)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create branch '{branch}': {e}")
|
||||
# Task is created, branch creation failed — log but don't crash
|
||||
notify_error(0, f"Branch creation failed: {e}")
|
||||
return
|
||||
# ORCH-088 (FR-1/AC-6, ADR-001 D1): DEFER the branch cut for an applicable repo.
|
||||
# Creating the Gitea branch here (T0, issue -> analysis) would cut it from `main`
|
||||
# BEFORE the predecessor is merged -> stale base. When the serial gate applies we
|
||||
# do NOT create the branch / initial docs now; the analyst-job sits in the queue
|
||||
# (status='queued', no branch) and the gate keeps it there until the predecessor
|
||||
# reaches stage='done'. The branch + docs are then materialised at claim time in
|
||||
# launcher._spawn from a fresh origin/main (anti-stale-base). The task row already
|
||||
# stores `branch` as a NAME (R-5) — only the git ref is deferred.
|
||||
from .. import serial_gate
|
||||
defer_branch = serial_gate.serial_gate_applies(repo)
|
||||
if not defer_branch:
|
||||
# Create branch in Gitea
|
||||
try:
|
||||
await _create_gitea_branch(repo, branch)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create branch '{branch}': {e}")
|
||||
# Task is created, branch creation failed — log but don't crash
|
||||
notify_error(0, f"Branch creation failed: {e}")
|
||||
return
|
||||
|
||||
# Create initial docs structure via Gitea API (create file)
|
||||
try:
|
||||
await _create_initial_docs(repo, branch, work_item_id, name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create initial docs: {e}")
|
||||
# Create initial docs structure via Gitea API (create file)
|
||||
try:
|
||||
await _create_initial_docs(repo, branch, work_item_id, name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create initial docs: {e}")
|
||||
else:
|
||||
logger.info(
|
||||
f"Task {work_item_id}: serial gate applies for {repo} -> deferring branch "
|
||||
f"cut to analyst-job claim (anti-stale-base, ORCH-088)"
|
||||
)
|
||||
|
||||
logger.info(f"Task created: {work_item_id} ({name}), branch={branch}, stage=analysis")
|
||||
|
||||
|
||||
@@ -98,4 +98,10 @@ def _disable_merge_verify(monkeypatch):
|
||||
# _handle_merge_verify's confirmed branch. Default it OFF too so unrelated
|
||||
# deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it.
|
||||
monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False)
|
||||
# ORCH-082: the merge-verify ensure_open_pr врезка makes REAL Gitea calls before
|
||||
# merge_pr. Default it OFF so unrelated deploy->done / merge-verify tests stay 1:1
|
||||
# (no network); the dedicated ORCH-082 tests re-enable it via their own monkeypatch.
|
||||
monkeypatch.setattr(
|
||||
_cfg.settings, "merge_verify_autocreate_pr_enabled", False, raising=False
|
||||
)
|
||||
yield
|
||||
|
||||
@@ -323,3 +323,87 @@ class TestActionStageNoChangesNote:
|
||||
def test_never_raises_on_bad_input(self):
|
||||
"""never-raise: odd inputs (None stage / None repo) degrade to None."""
|
||||
assert action_stage_no_changes_note(None, None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-087 (BR-EFF): agent_runs.effort migration + launch-time stamp
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestEffortStamp:
|
||||
"""TC-09/TC-10: the effort column is idempotent and stamped at launch."""
|
||||
|
||||
def _fresh_db(self, monkeypatch):
|
||||
import src.db as db_module
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
from src.db import init_db
|
||||
init_db()
|
||||
|
||||
def test_effort_migration_idempotent(self, monkeypatch):
|
||||
"""TC-09/AC-E.1: _ensure_column twice -> no error; column present."""
|
||||
self._fresh_db(monkeypatch)
|
||||
from src.db import init_db, get_db
|
||||
init_db() # second call must be a no-op
|
||||
conn = get_db()
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(agent_runs)").fetchall()]
|
||||
conn.close()
|
||||
assert "effort" in cols
|
||||
|
||||
def test_spawn_stamps_resolved_effort(self, tmp_path, monkeypatch):
|
||||
"""TC-10/AC-E.1: _spawn writes the REAL resolved --effort to agent_runs.
|
||||
|
||||
developer resolves to xhigh (ORCH-081 floor); the stamp must match that.
|
||||
All OS/process side-effects are faked so nothing is actually launched.
|
||||
"""
|
||||
self._fresh_db(monkeypatch)
|
||||
from src.db import get_db
|
||||
import src.agents.launcher as L
|
||||
|
||||
# A real repo dir so the isdir() guard passes; worktree is faked.
|
||||
repo = "orchestrator"
|
||||
(tmp_path / repo).mkdir()
|
||||
monkeypatch.setattr(L.settings, "repos_dir", str(tmp_path), raising=False)
|
||||
# ORCH-087: per-run log dir must be writable on a non-container host (CI runs
|
||||
# as a plain user where '/app' is denied). Point it at tmp_path so _spawn's
|
||||
# makedirs/open never touch the hardcoded '/app/data/runs'.
|
||||
monkeypatch.setattr(L.settings, "runs_dir", str(tmp_path / "runs"), raising=False)
|
||||
monkeypatch.setattr(L, "ensure_worktree", lambda r, b: str(tmp_path / repo))
|
||||
monkeypatch.setattr("src.projects.get_project_by_repo", lambda r: None)
|
||||
|
||||
# No --effort env overrides -> developer falls to its xhigh floor.
|
||||
monkeypatch.setattr(L.settings, "agent_effort_developer", "", raising=False)
|
||||
monkeypatch.setattr(L.settings, "agent_effort_default", "", raising=False)
|
||||
|
||||
# Fake the process + threads so nothing real runs.
|
||||
class _Proc:
|
||||
pid = 4242
|
||||
monkeypatch.setattr(L.subprocess, "Popen", lambda *a, **k: _Proc())
|
||||
|
||||
class _T:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
def start(self):
|
||||
pass
|
||||
monkeypatch.setattr(L.threading, "Thread", _T)
|
||||
monkeypatch.setattr(L, "notify_agent_started", lambda *a, **k: None)
|
||||
|
||||
# Seed a task row so _spawn can resolve the branch.
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
("p1", "ORCH-087", repo, "feature/ORCH-087-x", "development", "t"),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
launcher = L.AgentLauncher()
|
||||
run_id = launcher._spawn("developer", repo, task_content=None, task_id=tid)
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT effort FROM agent_runs WHERE id=?", (run_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row[0] == "xhigh"
|
||||
|
||||
159
tests/test_link_preview_disabled.py
Normal file
159
tests/test_link_preview_disabled.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""ORCH-080 — suppress Telegram link-preview in tracker/notify primitives.
|
||||
|
||||
Both low-level primitives ``send_telegram`` (POST /sendMessage) and
|
||||
``edit_telegram`` (POST /editMessageText) must add
|
||||
``"disable_web_page_preview": True`` to their JSON payload, so the Plane
|
||||
"Modern project management" banner no longer expands under every tracker card /
|
||||
notification. The clickable issue link must stay clickable -> ``parse_mode:
|
||||
"HTML"`` is preserved in both payloads, and the never-raise / return contracts
|
||||
are unchanged.
|
||||
|
||||
Network is isolated: ``src.notifications.httpx`` is patched; creds are stubbed.
|
||||
Test ids TC-01..TC-06 from 04-test-plan.yaml.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_link_preview.db")
|
||||
os.environ.setdefault("ORCH_DB_PATH", _test_db)
|
||||
|
||||
from src import notifications as N # noqa: E402
|
||||
|
||||
# conftest._no_telegram autouse-patches src.notifications.send_telegram to a
|
||||
# no-op for every test (prod-leak guard). Capture the REAL implementation at
|
||||
# import time (before any fixture runs) so these payload tests can exercise it.
|
||||
_REAL_SEND = N.send_telegram
|
||||
|
||||
|
||||
def _patch_tg_creds(monkeypatch):
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False)
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False)
|
||||
|
||||
|
||||
def _ok_resp(message_id=42):
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"ok": True, "result": {"message_id": message_id}}
|
||||
return resp
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-01 — send_telegram sets disable_web_page_preview: True
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_send_telegram_disables_link_preview(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
_REAL_SEND("hello")
|
||||
payload = hx.post.call_args.kwargs["json"]
|
||||
assert payload["disable_web_page_preview"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-02 — edit_telegram sets disable_web_page_preview: True
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_edit_telegram_disables_link_preview(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
N.edit_telegram(1, "hello")
|
||||
payload = hx.post.call_args.kwargs["json"]
|
||||
assert payload["disable_web_page_preview"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-03 — parse_mode HTML preserved in both payloads (clickable <a href>)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_send_telegram_keeps_parse_mode_html(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
_REAL_SEND("hello")
|
||||
assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML"
|
||||
|
||||
|
||||
def test_edit_telegram_keeps_parse_mode_html(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
N.edit_telegram(1, "hello")
|
||||
assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-04 — send_telegram preserves existing fields + disable_notification arg
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_send_telegram_preserves_existing_fields(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
_REAL_SEND("body", disable_notification=True)
|
||||
payload = hx.post.call_args.kwargs["json"]
|
||||
assert payload["chat_id"] == "C"
|
||||
assert payload["text"] == "body"
|
||||
assert payload["parse_mode"] == "HTML"
|
||||
assert payload["disable_notification"] is True
|
||||
|
||||
|
||||
def test_send_telegram_disable_notification_default_false(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
_REAL_SEND("body")
|
||||
assert hx.post.call_args.kwargs["json"]["disable_notification"] is False
|
||||
|
||||
|
||||
def test_edit_telegram_preserves_existing_fields(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
N.edit_telegram(7, "body")
|
||||
payload = hx.post.call_args.kwargs["json"]
|
||||
assert payload["chat_id"] == "C"
|
||||
assert payload["message_id"] == 7
|
||||
assert payload["text"] == "body"
|
||||
assert payload["parse_mode"] == "HTML"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-05 — return contracts unchanged
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_send_telegram_returns_message_id(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp(message_id=99)
|
||||
assert _REAL_SEND("x") == 99
|
||||
|
||||
|
||||
def test_send_telegram_returns_none_without_creds(monkeypatch):
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False)
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False)
|
||||
assert _REAL_SEND("x") is None
|
||||
|
||||
|
||||
def test_edit_telegram_returns_edit_ok(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _ok_resp()
|
||||
assert N.edit_telegram(1, "x") == N.EDIT_OK
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-06 — never-raise: httpx.post raising -> None / EDIT_FAILED
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_send_telegram_never_raises(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.side_effect = Exception("boom")
|
||||
assert _REAL_SEND("x") is None
|
||||
|
||||
|
||||
def test_edit_telegram_never_raises(monkeypatch):
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.side_effect = Exception("boom")
|
||||
assert N.edit_telegram(1, "x") == N.EDIT_FAILED
|
||||
222
tests/test_notifications_orphans.py
Normal file
222
tests/test_notifications_orphans.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""ORCH-087 (BR-G1): tracker_messages ledger — no orphaned cards in bump mode.
|
||||
|
||||
The scalar tasks.tracker_message_id only ever knew the LAST mid, so any lost
|
||||
reference (delete-fail+send-ok, race, restart) orphaned older cards forever. The
|
||||
additive tracker_messages ledger lets every bump delete ALL still-open mids, not
|
||||
just the last one. These tests model the dominant orphan generators (vopros 2 in
|
||||
ADR-001) with Telegram fully mocked (no network).
|
||||
|
||||
Covers TC-01..TC-05 / AC-1.2, AC-1.3, AC-X.1.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_orphans.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
import src.db as db_module # noqa: E402
|
||||
from src.db import ( # noqa: E402
|
||||
init_db, get_db, get_tracker_message_id, set_tracker_message_id,
|
||||
add_tracker_message, get_open_tracker_messages, mark_tracker_message_deleted,
|
||||
)
|
||||
from src import notifications as N # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
# Keep the render cheap & deterministic (no real Telegram / Plane).
|
||||
monkeypatch.setattr(N, "render_task_tracker", lambda task_id: "CARD")
|
||||
_bump_mode(monkeypatch)
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _bump_mode(monkeypatch):
|
||||
monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False)
|
||||
|
||||
|
||||
def _mk_task(stage="development", wid="ORCH-087"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("p1", wid, "orchestrator", "feature/ORCH-087-x", stage, "orphan test"),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ledger helpers (direct DB contract)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ledger_add_get_mark(monkeypatch):
|
||||
"""add -> open set; mark_deleted -> drops out; INSERT OR IGNORE idempotent."""
|
||||
tid = _mk_task()
|
||||
add_tracker_message(tid, 10)
|
||||
add_tracker_message(tid, 11)
|
||||
add_tracker_message(tid, 10) # duplicate -> ignored, no resurrection
|
||||
assert get_open_tracker_messages(tid) == [10, 11]
|
||||
mark_tracker_message_deleted(tid, 10)
|
||||
assert get_open_tracker_messages(tid) == [11]
|
||||
# re-add of a deleted mid is ignored (PK exists) -> stays deleted.
|
||||
add_tracker_message(tid, 10)
|
||||
assert get_open_tracker_messages(tid) == [11]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-01: bump deletes ALL known open mids, not just the last
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bump_deletes_all_open_mids(monkeypatch):
|
||||
"""TC-01/AC-1.2: every still-open card is deleted on the next bump."""
|
||||
tid = _mk_task()
|
||||
# Three orphans accumulated in the ledger from earlier desyncs.
|
||||
for m in (100, 101, 102):
|
||||
add_tracker_message(tid, m)
|
||||
set_tracker_message_id(tid, 102) # scalar only knows the last one
|
||||
|
||||
deleted = []
|
||||
monkeypatch.setattr(N, "delete_telegram",
|
||||
lambda mid: deleted.append(mid) or True)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False: 200)
|
||||
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
assert sorted(deleted) == [100, 101, 102] # ALL open mids deleted
|
||||
# Old ones marked gone; only the new card is open.
|
||||
assert get_open_tracker_messages(tid) == [200]
|
||||
assert get_tracker_message_id(tid) == 200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-02: send -> None keeps the ledger/pointer intact (BR-6 / R-3)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_send_none_keeps_ledger_and_pointer(monkeypatch):
|
||||
"""TC-02/AC-1.3: send fails -> no new mid recorded, pointer not wiped."""
|
||||
tid = _mk_task()
|
||||
add_tracker_message(tid, 100)
|
||||
set_tracker_message_id(tid, 100)
|
||||
|
||||
# delete fails transiently so 100 stays open (alive); send returns None.
|
||||
monkeypatch.setattr(N, "delete_telegram", lambda mid: False)
|
||||
sends = []
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
sends.append(1) or None)
|
||||
|
||||
N.update_task_tracker(tid) # must not raise
|
||||
|
||||
assert len(sends) == 1 # exactly one attempt
|
||||
assert get_tracker_message_id(tid) == 100 # pointer preserved
|
||||
assert get_open_tracker_messages(tid) == [100] # 100 still tracked for retry
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-03: delete False -> stays open; "already gone" -> dropped
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_delete_transient_stays_open_gone_dropped(monkeypatch):
|
||||
"""TC-03: transient-delete mid retried next bump; gone mid excluded."""
|
||||
tid = _mk_task()
|
||||
add_tracker_message(tid, 100) # will fail transiently -> stays
|
||||
add_tracker_message(tid, 101) # will be 'gone' (True) -> dropped
|
||||
|
||||
def _del(mid):
|
||||
return mid != 100 # 100 -> False (transient), 101 -> True (gone)
|
||||
|
||||
monkeypatch.setattr(N, "delete_telegram", _del)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False: 300)
|
||||
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
# 100 still open (retry), 101 marked deleted, 300 new card open.
|
||||
assert set(get_open_tracker_messages(tid)) == {100, 300}
|
||||
assert get_tracker_message_id(tid) == 300
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-04: rapid repeats / race -> one live card, <=1 send per call
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_repeated_bumps_converge_to_one_card(monkeypatch):
|
||||
"""TC-04/AC-X.1: repeated bumps self-heal to exactly one open card."""
|
||||
tid = _mk_task()
|
||||
|
||||
seq = iter([501, 502, 503, 504])
|
||||
sends_per_call = []
|
||||
|
||||
def _send(text, disable_notification=False):
|
||||
sends_per_call.append(1)
|
||||
return next(seq)
|
||||
|
||||
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
|
||||
monkeypatch.setattr(N, "send_telegram", _send)
|
||||
|
||||
for _ in range(4):
|
||||
before = len(sends_per_call)
|
||||
N.update_task_tracker(tid)
|
||||
assert len(sends_per_call) - before == 1 # <=1 send per call
|
||||
|
||||
# After the last bump only the newest card is open; all earlier deleted.
|
||||
assert get_open_tracker_messages(tid) == [504]
|
||||
assert get_tracker_message_id(tid) == 504
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TC-05: ledger survives a "restart" (read from DB) -> old cards cleaned
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ledger_survives_restart(monkeypatch):
|
||||
"""TC-05/AC-1.3: mids persisted in DB are cleaned on the next bump."""
|
||||
tid = _mk_task()
|
||||
# Simulate a previous process that created two cards but lost the scalar to
|
||||
# one of them (orphan): both are in the ledger though.
|
||||
add_tracker_message(tid, 700)
|
||||
add_tracker_message(tid, 701)
|
||||
set_tracker_message_id(tid, 701) # scalar lost 700
|
||||
|
||||
deleted = []
|
||||
monkeypatch.setattr(N, "delete_telegram",
|
||||
lambda mid: deleted.append(mid) or True)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False: 800)
|
||||
|
||||
# "Fresh process" reads the ledger straight from the DB.
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
assert sorted(deleted) == [700, 701] # the orphan 700 is reaped too
|
||||
assert get_open_tracker_messages(tid) == [800]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# never-raise on ledger/DB explosion
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bump_never_raises_on_ledger_error(monkeypatch):
|
||||
"""AC-X.2: a ledger read blowing up does not break the bump path."""
|
||||
tid = _mk_task()
|
||||
monkeypatch.setattr(N, "get_open_tracker_messages",
|
||||
lambda task_id: (_ for _ in ()).throw(RuntimeError("db")),
|
||||
raising=False)
|
||||
# Even if the import-bound name is used, force the failure via db module too.
|
||||
monkeypatch.setattr(db_module, "get_open_tracker_messages",
|
||||
lambda task_id: (_ for _ in ()).throw(RuntimeError("db")),
|
||||
raising=False)
|
||||
sent = []
|
||||
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
sent.append(1) or 900)
|
||||
# Must not raise; still sends the fresh card.
|
||||
N.update_task_tracker(tid)
|
||||
assert sent == [1]
|
||||
@@ -148,8 +148,12 @@ def test_reconciler_skip_helper_honours_block(monkeypatch):
|
||||
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)
|
||||
# Bypass Guard 2 (networked) so we isolate Guard 3. ORCH-086: the production
|
||||
# call now passes the resolved (states, state_uuid), so accept extra args.
|
||||
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda *a, **k: False)
|
||||
# ORCH-086: the D1 resolve now runs before Guard 2 (for the terminal-skip) —
|
||||
# keep it offline so this Guard-3 test stays deterministic.
|
||||
monkeypatch.setattr(r, "_resolve_issue_status", lambda task: ({}, {}, None))
|
||||
|
||||
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
|
||||
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}
|
||||
|
||||
163
tests/test_orch082_ensure_pr.py
Normal file
163
tests/test_orch082_ensure_pr.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""ORCH-082 FR-1 — merge_gate.ensure_open_pr: idempotent open-code-PR actor.
|
||||
|
||||
Covers TC-01..05 / AC-2 / AC-6 / AC-7. The actor guarantees an open code-PR
|
||||
(``head==branch`` AND ``base=="main"``) exists before the deterministic ``merge_pr``,
|
||||
without ever creating a duplicate. Gitea HTTP is mocked; the actor honours the strict
|
||||
never-raise contract (any error -> ``("failed", reason)``).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
REPO = "orchestrator"
|
||||
BRANCH = "feature/ORCH-082-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
"""Minimal httpx.Response stand-in (status_code + json/text)."""
|
||||
|
||||
def __init__(self, status_code, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
|
||||
|
||||
def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None):
|
||||
"""Patch merge_gate's lazily-imported httpx with stub get/post callables."""
|
||||
import httpx
|
||||
|
||||
def fake_get(url, *a, **k):
|
||||
if record is not None:
|
||||
record.append(("GET", url, k.get("params")))
|
||||
return get_resp() if callable(get_resp) else get_resp
|
||||
|
||||
def fake_post(url, *a, **k):
|
||||
if record is not None:
|
||||
record.append(("POST", url, k.get("json")))
|
||||
if post_resp is None:
|
||||
raise AssertionError("POST must NOT be called")
|
||||
return post_resp() if callable(post_resp) else post_resp
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01: no open code-PR -> POST creates one -> ("created", N); base==main filter.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_creates_pr_when_absent(monkeypatch):
|
||||
record = []
|
||||
_install_httpx(
|
||||
monkeypatch,
|
||||
get_resp=_Resp(200, []), # no open PRs at all
|
||||
post_resp=_Resp(201, {"number": 42}),
|
||||
record=record,
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("created", "42")
|
||||
# POST body targets head=branch, base=main.
|
||||
post = [r for r in record if r[0] == "POST"][0]
|
||||
assert post[2]["head"] == BRANCH
|
||||
assert post[2]["base"] == "main"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: an open code-PR (head==branch AND base==main) already exists -> existed,
|
||||
# POST is never called (no duplicate).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_existed_no_duplicate(monkeypatch):
|
||||
payload = [{"number": 7, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]
|
||||
_install_httpx(monkeypatch, get_resp=_Resp(200, payload), post_resp=None)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("existed", "7") # POST stub would raise if called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 (AC-6): only a docs-PR (base != main) exists -> NOT a code-PR -> create on main.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_docs_pr_not_counted_creates_on_main(monkeypatch):
|
||||
record = []
|
||||
# An open PR exists but onto a docs base, and another onto a different head.
|
||||
docs_payload = [
|
||||
{"number": 9, "head": {"ref": BRANCH}, "base": {"ref": "docs/logs"}},
|
||||
{"number": 10, "head": {"ref": "other/branch"}, "base": {"ref": "main"}},
|
||||
]
|
||||
_install_httpx(
|
||||
monkeypatch,
|
||||
get_resp=_Resp(200, docs_payload),
|
||||
post_resp=_Resp(201, {"number": 11}),
|
||||
record=record,
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("created", "11")
|
||||
assert any(r[0] == "POST" for r in record)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (AC-7): Gitea GET/POST raise -> ("failed", reason), never raises.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_never_raise_on_get_error(monkeypatch):
|
||||
import httpx
|
||||
|
||||
def boom(*a, **k):
|
||||
raise httpx.ConnectError("gitea down")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
monkeypatch.setattr(httpx, "post", boom)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert status == "failed"
|
||||
assert detail # carries a reason
|
||||
|
||||
|
||||
def test_tc04_never_raise_on_post_error(monkeypatch):
|
||||
import httpx
|
||||
|
||||
def boom_post(*a, **k):
|
||||
raise httpx.ConnectError("post exploded")
|
||||
|
||||
_install_httpx(monkeypatch, get_resp=_Resp(200, []), post_resp=None)
|
||||
monkeypatch.setattr(httpx, "post", boom_post)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert status == "failed"
|
||||
|
||||
|
||||
def test_tc04_failed_when_post_non_2xx(monkeypatch):
|
||||
# A plain non-2xx, non-conflict POST -> failed (not silently swallowed).
|
||||
_install_httpx(
|
||||
monkeypatch, get_resp=_Resp(200, []), post_resp=_Resp(500, text="boom")
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert status == "failed"
|
||||
assert "500" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 (AC-2 / FR-5): race -> POST returns 409/422 "PR exists" -> re-GET confirms
|
||||
# the existing PR -> ("existed", N), no duplicate.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("conflict_code", [409, 422])
|
||||
def test_tc05_race_post_conflict_confirms_existing(monkeypatch, conflict_code):
|
||||
# First GET: no PR (so we attempt POST). POST: conflict. Re-GET: PR now present.
|
||||
gets = iter([
|
||||
_Resp(200, []), # first probe: absent
|
||||
_Resp(200, [{"number": 99, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]),
|
||||
])
|
||||
_install_httpx(
|
||||
monkeypatch,
|
||||
get_resp=lambda: next(gets),
|
||||
post_resp=_Resp(conflict_code, text="pull request already exists"),
|
||||
)
|
||||
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
|
||||
assert (status, detail) == ("existed", "99")
|
||||
183
tests/test_orch082_merge_verify_autocreate.py
Normal file
183
tests/test_orch082_merge_verify_autocreate.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""ORCH-082 FR-2/FR-3/FR-4 — ensure_open_pr врезка in _handle_merge_verify.
|
||||
|
||||
Covers TC-06..12 / AC-3 / AC-4 / AC-5 / AC-7 / AC-8 / AC-9 / FR-5. Calls the
|
||||
``deploy -> done`` under-gate handler directly with mocked merge_gate primitives +
|
||||
side effects (Plane/Telegram). Asserts the return contract: ``False`` == advance to
|
||||
``done``, ``True`` == HOLD (alert, NOT done). The ORCH-073 SHA-in-main proof stays
|
||||
authoritative — auto-creating a PR must NEVER mask un-merged code.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch082.db"))
|
||||
|
||||
import logging # noqa: E402
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-082"
|
||||
BRANCH = "feature/ORCH-082-x"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _wire(monkeypatch):
|
||||
# Under-gate in scope; autocreate ON; regression guard OFF (its own tests cover it).
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
# Silence Plane/Telegram side effects (assert on .called where relevant).
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 (AC-3): PR absent -> ensure_open_pr creates -> merge_pr -> verify True ->
|
||||
# deploy->done with NO false HOLD.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_autocreate_then_merge_then_done(monkeypatch):
|
||||
ensure = MagicMock(return_value=("created", "5"))
|
||||
merge = MagicMock(return_value=(True, "merged PR #5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert res.alerted is False
|
||||
ensure.assert_called_once_with(REPO, BRANCH)
|
||||
assert merge.called
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 (AC-4 / FR-3): PR created/merged but verify_merged_to_main=False (code not
|
||||
# in main) -> HOLD + set_issue_blocked, NOT done, no rollback. ORCH-073 protection
|
||||
# is untouched by auto-create.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_verify_false_still_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.note == "merge-not-verified-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 (AC-7 / AC-5): ensure_open_pr -> failed -> honest HOLD with distinguishable
|
||||
# text/note; merge_pr is NOT reached; advance_stage does not raise.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_ensure_failed_holds_distinct(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down")
|
||||
)
|
||||
merge = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.note == "pr-create-failed-hold" # distinct from "merge-not-verified-hold"
|
||||
assert not merge.called # merge_pr never reached
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (AC-8): kill-switch OFF -> ensure_open_pr NOT called; "no open PR" -> prior
|
||||
# HOLD 1:1 (ORCH-074 behaviour reproduced).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_killswitch_off_no_autocreate(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", False)
|
||||
ensure = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
# merge_pr finds no open PR -> verify False -> prior not-merged HOLD.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (False, "no open PR"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True
|
||||
assert res.note == "merge-not-verified-hold" # exactly the prior HOLD
|
||||
assert not ensure.called # auto-create skipped entirely
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 (AC-9): non-self repo (merge_verify_applies=False) -> врезка no-op, neither
|
||||
# ensure_open_pr nor merge_pr called.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_non_self_repo_noop(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
ensure = MagicMock()
|
||||
merge = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, "enduro-trails", "ET-1", "feature/x", res)
|
||||
|
||||
assert intervened is False # advance unchanged
|
||||
assert not ensure.called
|
||||
assert not merge.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-2 / FR-5): idempotent re-drive (reaper/reconciler) -> ensure existed,
|
||||
# merge_pr already-merged -> verify True -> done, no duplicate PR.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_idempotent_redrive(monkeypatch):
|
||||
ensure = MagicMock(return_value=("existed", "5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "already-merged"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert ensure.return_value[0] == "existed"
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 (AC-5): logs distinguish created/existed/failed; the create-failed HOLD text
|
||||
# differs from the not-merged HOLD text.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc12_logs_distinguish_outcomes(monkeypatch, caplog):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="orchestrator"):
|
||||
_handle_merge_verify(1, REPO, WI, BRANCH, AdvanceResult())
|
||||
assert any("ensure_open_pr -> created" in r.message for r in caplog.records)
|
||||
|
||||
# create-failed note differs from not-merged note (text-distinguishable HOLD).
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down")
|
||||
)
|
||||
res = AdvanceResult()
|
||||
_handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
assert res.note == "pr-create-failed-hold"
|
||||
assert res.note != "merge-not-verified-hold"
|
||||
@@ -79,6 +79,11 @@ def setup(monkeypatch):
|
||||
monkeypatch.setattr(P.settings, "db_path", _test_db)
|
||||
import src.db as _db
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
# ORCH-088: these are pre-ORCH-088 repo-routing tests that assert the branch is
|
||||
# cut DURING start_pipeline. With the serial gate ON (default) the branch cut is
|
||||
# deferred to the analyst-job claim, so pin them to the kill-switch-off (legacy)
|
||||
# path — branch timing is out of scope here (covered by test_serial_gate_branch).
|
||||
monkeypatch.setattr(_db.settings, "serial_gate_enabled", False, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
|
||||
61
tests/test_queue_endpoint.py
Normal file
61
tests/test_queue_endpoint.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""ORCH-088 — GET /queue additive serial_gate block (AC-10 / TC-20).
|
||||
|
||||
The /queue payload must gain an additive ``serial_gate`` block WITHOUT changing
|
||||
any pre-existing key (counts/max_concurrency/reconcile/reaper/post_deploy/
|
||||
task_deps/recent ...).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_queue_endpoint.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 # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "q.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_queue_has_serial_gate_block_and_keeps_existing_keys():
|
||||
import asyncio
|
||||
from src import main
|
||||
|
||||
payload = asyncio.run(main.queue())
|
||||
|
||||
# Pre-existing keys are all still present (no contract break).
|
||||
for key in (
|
||||
"counts", "max_concurrency", "poll_interval", "resilience", "reconcile",
|
||||
"reaper", "post_deploy", "merge_verify", "task_deps", "recent",
|
||||
):
|
||||
assert key in payload, f"existing /queue key '{key}' must be preserved"
|
||||
|
||||
# New additive block.
|
||||
assert "serial_gate" in payload
|
||||
sg = payload["serial_gate"]
|
||||
assert sg["enabled"] is True
|
||||
assert "repos" in sg and "freeze_enabled" in sg
|
||||
assert isinstance(sg["per_repo"], dict)
|
||||
|
||||
|
||||
def test_queue_serial_gate_reflects_freeze():
|
||||
import asyncio
|
||||
from src import main
|
||||
from src import serial_gate
|
||||
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-900")
|
||||
payload = asyncio.run(main.queue())
|
||||
per = payload["serial_gate"]["per_repo"]
|
||||
assert "orchestrator" in per
|
||||
assert per["orchestrator"]["frozen"] is True
|
||||
assert per["orchestrator"]["frozen_reason"] == "DEGRADED"
|
||||
@@ -744,3 +744,233 @@ def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
|
||||
assert _guard2(monkeypatch, aliased, "done-u") is False
|
||||
# The explicit human gates still skip.
|
||||
assert _guard2(monkeypatch, aliased, "blocked-u") is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ORCH-086: terminal-skip + state_uuid dedup on the F-1 (gate-side) path.
|
||||
# Closes the gap of ORCH-068 (which covered only F-2). The spurious
|
||||
# "ET-002 ... разблокирована (потерян webhook)" notification for a task that is
|
||||
# already terminal in Plane (but drifted in the orchestrator DB) is suppressed.
|
||||
# ===========================================================================
|
||||
def _plane_terminal(monkeypatch, *, state_uuid="done-uuid",
|
||||
states=None, groups=None):
|
||||
"""Make Plane report ``state_uuid`` as the issue's current state, with the
|
||||
given {key->uuid} states and {uuid->group} groups maps."""
|
||||
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=state_uuid))
|
||||
monkeypatch.setattr(reconciler_mod, "get_project_states",
|
||||
MagicMock(return_value=states if states is not None
|
||||
else {"done": "done-uuid"}))
|
||||
monkeypatch.setattr(reconciler_mod, "get_project_state_groups",
|
||||
MagicMock(return_value=groups if groups is not None
|
||||
else {"done-uuid": "completed"}))
|
||||
|
||||
|
||||
# --- TC-86-01 (AC-1) -------------------------------------------------------
|
||||
def test_tc86_01_terminal_in_plane_not_unblocked(monkeypatch):
|
||||
"""enduro task NOT-done in the DB but terminal in Plane (group=completed),
|
||||
green gate: F-1 must NOT call _note_unblock / send_telegram — neither on a
|
||||
normal tick nor on the first pass of a fresh Reconciler (clean dedup)."""
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
note = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
|
||||
_plane_terminal(monkeypatch) # Plane says Done (group=completed)
|
||||
|
||||
task_id = _make_task("development", wi="ET-002", age_s=3600)
|
||||
|
||||
# Fresh Reconciler -> empty _unblock_dedup -> the "first pass after restart"
|
||||
# symptom is exercised; the terminal-skip must fire regardless of dedup.
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development" # never advanced
|
||||
note.assert_not_called()
|
||||
tg.assert_not_called()
|
||||
assert rec.unblocked_total == 0
|
||||
assert rec.skipped_terminal_total >= 1
|
||||
|
||||
|
||||
# --- TC-86-02 (AC-2) -------------------------------------------------------
|
||||
def test_tc86_02_terminal_skip_counter_no_advance(monkeypatch):
|
||||
"""Terminal-skip bumps skipped_terminal_total and never reaches
|
||||
advance_if_gate_passed."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(monkeypatch)
|
||||
|
||||
_make_task("development", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-03 (AC-2 / R1) --------------------------------------------------
|
||||
def test_tc86_03_terminal_by_group_cancelled(monkeypatch):
|
||||
"""Terminal detection by Plane state GROUP works for cancelled too, and is
|
||||
project-independent (group discriminator, not a per-project key)."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(
|
||||
monkeypatch, state_uuid="cancel-uuid",
|
||||
states={"done": "done-uuid", "cancelled": "cancel-uuid"},
|
||||
groups={"cancel-uuid": "cancelled"},
|
||||
)
|
||||
|
||||
_make_task("development", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-04 (AC-2 / R1) --------------------------------------------------
|
||||
def test_tc86_04_terminal_fallback_logical_key_empty_groups(monkeypatch):
|
||||
"""Fallback when groups are unavailable ({}): terminality by the project's
|
||||
logical done/cancelled key."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(
|
||||
monkeypatch, state_uuid="done-key-uuid",
|
||||
states={"done": "done-key-uuid", "cancelled": "cancel-key-uuid"},
|
||||
groups={}, # group unknown -> logical-key fallback
|
||||
)
|
||||
|
||||
_make_task("development", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-05 (AC-2) -------------------------------------------------------
|
||||
def test_tc86_05_terminal_by_db_stage_cancelled(monkeypatch):
|
||||
"""DB-side terminal drift: a task with stage='cancelled' (NOT filtered by
|
||||
get_active_tasks_for_reconcile, which only drops 'done') is skipped locally
|
||||
without reaching _note_unblock / advance — and bumps skipped_terminal_total."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
note = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
|
||||
# A networked resolve must not even be needed for the DB-side guard.
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(side_effect=AssertionError("must not hit Plane for DB-cancelled")),
|
||||
)
|
||||
|
||||
_make_task("cancelled", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
note.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-06 (AC-3) -------------------------------------------------------
|
||||
def test_tc86_06_legit_unblock_passes_state_uuid(monkeypatch):
|
||||
"""A legitimate unblock calls _note_unblock with a non-empty state_uuid; the
|
||||
dedup guard stores issue_id -> state_uuid."""
|
||||
_green_ci(monkeypatch)
|
||||
# Default fixture: fetch_issue_state -> 'some-non-gated-state', groups {} ->
|
||||
# not terminal, not blocked -> the task advances.
|
||||
task_id = _make_task("development", wi="ET-300", age_s=3600)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "review"
|
||||
assert rec.unblocked_total == 1
|
||||
assert rec._unblock_dedup.get("ET-300") == "some-non-gated-state"
|
||||
|
||||
|
||||
# --- TC-86-07 (AC-3) -------------------------------------------------------
|
||||
def test_tc86_07_repeat_tick_deduped(monkeypatch):
|
||||
"""A repeat F-1 tick for the same issue+state_uuid is suppressed by the dedup
|
||||
guard: deduped_total += 1 and no second send_telegram."""
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
# advance "succeeds" but leaves the stage put, so each tick reaches
|
||||
# _note_unblock again with the SAME resolved state_uuid.
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "advance_if_gate_passed",
|
||||
MagicMock(return_value=MagicMock(advanced=True)),
|
||||
)
|
||||
|
||||
_make_task("development", wi="ET-301", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once() # first: notifies
|
||||
rec.reconcile_gate_once() # second: same issue+state -> deduped
|
||||
|
||||
assert tg.call_count == 1
|
||||
assert rec.unblocked_total == 1
|
||||
assert rec.deduped_total == 1
|
||||
|
||||
|
||||
# --- TC-86-08 (AC-4, anti-regress) -----------------------------------------
|
||||
def test_tc86_08_legit_unblock_still_notifies(monkeypatch):
|
||||
"""A NON-terminal genuinely stuck task (working Plane status, past grace, no
|
||||
active job, green gate) is STILL advanced and notifies exactly once."""
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
|
||||
task_id = _make_task("development", wi="ET-302", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "review"
|
||||
tg.assert_called_once()
|
||||
assert rec.unblocked_total == 1
|
||||
assert rec.skipped_terminal_total == 0
|
||||
|
||||
|
||||
# --- TC-86-09 (AC-5, never-raise) ------------------------------------------
|
||||
def test_tc86_09_never_raise_no_false_notify(monkeypatch):
|
||||
"""An exception in the terminal-detect / fetch_issue_state path does not blow
|
||||
up the tick AND does not produce a false unblock (conservative)."""
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(side_effect=RuntimeError("plane boom")),
|
||||
)
|
||||
|
||||
task_id = _make_task("development", wi="ET-303", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once() # must not raise
|
||||
|
||||
# resolve failed -> state_uuid None -> not terminal, Guard 2 conservative skip.
|
||||
assert _stage_of(task_id) == "development"
|
||||
tg.assert_not_called()
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# --- TC-86-11 (AC-6) -------------------------------------------------------
|
||||
def test_tc86_11_terminal_skip_independent_of_guard2_flag(monkeypatch):
|
||||
"""reconcile_skip_blocked_enabled=False (Guard 2 escape hatch) does NOT
|
||||
disable the unconditional terminal-skip: a terminal task is still skipped."""
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.settings, "reconcile_skip_blocked_enabled", False
|
||||
)
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(monkeypatch) # group=completed
|
||||
|
||||
_make_task("development", wi="ET-304", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
@@ -684,3 +684,18 @@ def test_tc10_done_silent_on_all_projects(monkeypatch):
|
||||
assert recon.unblocked_total == 0
|
||||
assert recon.skipped_terminal_total >= 2 # one per project
|
||||
assert _job_count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-86-10 (AC-6): the status()/GET-queue observability shape is unchanged by
|
||||
# ORCH-086 — the ORCH-068 counters (skipped_terminal_total / deduped_total /
|
||||
# unblocked_total) are still present, so the F-2 regression contract holds.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc86_10_status_shape_unchanged():
|
||||
snap = Reconciler().status()
|
||||
for key in (
|
||||
"enabled", "plane_enabled", "interval", "last_run_ts",
|
||||
"unblocked_total", "last_unblocked",
|
||||
"skipped_terminal_total", "deduped_total",
|
||||
):
|
||||
assert key in snap, f"status() missing observability key: {key}"
|
||||
|
||||
@@ -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
|
||||
|
||||
188
tests/test_serial_gate.py
Normal file
188
tests/test_serial_gate.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""ORCH-088 — per-repo serial gate, unit tests (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-01 claim_next_job does NOT claim an analyst-job of a NEW task B while the
|
||||
repo has an unfinished task A (gate closed).
|
||||
TC-02 serial_gate_applies: enabled + empty CSV -> True for any repo; CSV
|
||||
membership -> True; repo outside CSV -> False; disabled -> False.
|
||||
TC-03 jobs of an ALREADY-active task (architect/developer/.../deployer) are
|
||||
never gated — the single active task advances freely.
|
||||
TC-08 per-repo: an active orchestrator task does NOT gate an enduro analyst-job.
|
||||
TC-15 kill-switch off -> claim is 1:1 as before ORCH-088.
|
||||
TC-16 repo outside a non-empty CSV -> gate inert for that repo.
|
||||
TC-17 DB/build error in the gate -> fail-OPEN: claim does not crash, still claims.
|
||||
TC-19 snapshot() shape + never-raise.
|
||||
TC-21 STAGE_TRANSITIONS / QG_CHECKS registries unchanged (no new QG check).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate.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 serial_gate # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "sg.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
# Feature ON by default; freeze layer ON; empty CSV (all repos).
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
# Keep the unrelated dep-gate inert so claim semantics isolate the serial gate.
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
||||
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, repo, f"feature/{work_item_id}", stage, work_item_id),
|
||||
)
|
||||
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-01
|
||||
def test_gate_closed_when_repo_has_active_task():
|
||||
_make_task("ORCH-201", stage="development") # active predecessor
|
||||
b = _make_task("ORCH-202", stage="analysis") # new task
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
# A is unfinished -> the analyst-job of B is NOT claimable.
|
||||
assert claim_next_job() is None, "analyst-job must be gated by active task A"
|
||||
# /queue shows B waiting + an active task for the repo.
|
||||
snap = serial_gate.snapshot()
|
||||
per = snap["per_repo"]["orchestrator"]
|
||||
assert per["active_task"]["work_item_id"] == "ORCH-201"
|
||||
assert any(w["job_id"] == job_b for w in per["waiting"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-02
|
||||
def test_serial_gate_applies_scopes(monkeypatch):
|
||||
# enabled + empty CSV -> all repos.
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is True
|
||||
assert serial_gate.serial_gate_applies("enduro-trails") is True
|
||||
# CSV membership.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "orchestrator", raising=False)
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is True
|
||||
assert serial_gate.serial_gate_applies("enduro-trails") is False
|
||||
# kill-switch off -> never applies.
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False)
|
||||
assert serial_gate.serial_gate_applies("orchestrator") is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-03
|
||||
def test_non_analyst_job_of_active_task_passes():
|
||||
a = _make_task("ORCH-210", stage="development")
|
||||
# an unrelated unfinished task in the same repo (would close the gate for analyst)
|
||||
_make_task("ORCH-211", stage="analysis")
|
||||
for role in ("architect", "developer", "reviewer", "tester", "deployer"):
|
||||
jid = enqueue_job(role, "orchestrator", role, task_id=a)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == jid, (
|
||||
f"{role}-job of an active task must never be gated"
|
||||
)
|
||||
# finish it so the next role's job is the only queued one.
|
||||
db.mark_job(jid, "done")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-08
|
||||
def test_per_repo_isolation():
|
||||
# orchestrator busy; enduro gets a brand-new analyst-job.
|
||||
_make_task("ORCH-220", stage="development", repo="orchestrator")
|
||||
b = _make_task("ET-220", stage="analysis", repo="enduro-trails")
|
||||
job_b = enqueue_job("analyst", "enduro-trails", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"orchestrator's active task must not gate enduro's analyst-job"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-15
|
||||
def test_kill_switch_off_is_inert(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False)
|
||||
_make_task("ORCH-230", stage="development") # active task
|
||||
b = _make_task("ORCH-231", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"with the kill-switch off the gate must be inert (claims as before)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-16
|
||||
def test_repo_outside_csv_not_gated(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "enduro-trails", raising=False)
|
||||
_make_task("ORCH-240", stage="development") # active orchestrator task
|
||||
b = _make_task("ORCH-241", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"orchestrator is outside the CSV scope -> gate must not apply"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-17
|
||||
def test_build_clause_error_fails_open(monkeypatch):
|
||||
"""A build error in the gate clause must fail-OPEN (claim still proceeds)."""
|
||||
_make_task("ORCH-250", stage="development") # would close the gate
|
||||
b = _make_task("ORCH-251", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("clause build down")
|
||||
|
||||
monkeypatch.setattr(serial_gate, "build_claim_clause", _boom, raising=True)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"a gate build error must fail-OPEN, not wedge the queue (AC-8)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-19
|
||||
def test_snapshot_shape_and_never_raises(monkeypatch):
|
||||
snap = serial_gate.snapshot()
|
||||
assert snap["enabled"] is True
|
||||
assert "repos" in snap and "freeze_enabled" in snap
|
||||
assert isinstance(snap["per_repo"], dict)
|
||||
# never-raise: a DB failure -> minimal dict with flags, empty per_repo.
|
||||
monkeypatch.setattr(
|
||||
serial_gate, "_known_repos",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
raising=True,
|
||||
)
|
||||
snap2 = serial_gate.snapshot()
|
||||
assert snap2["per_repo"] == {}
|
||||
assert snap2["enabled"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-21
|
||||
def test_registries_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
from src.qg.checks import QG_CHECKS
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done",
|
||||
}
|
||||
# No serial-gate QG check was introduced (the gate is a scheduler condition).
|
||||
assert not any("serial" in k for k in QG_CHECKS), "no new QG check expected"
|
||||
153
tests/test_serial_gate_branch.py
Normal file
153
tests/test_serial_gate_branch.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""ORCH-088 — deferred branch cut / anti-stale-base (FR-1/AC-6).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-13 while the serial gate applies, start_pipeline does NOT create the Gitea
|
||||
branch / initial docs (the cut is deferred to the analyst-job claim);
|
||||
with the kill-switch off it creates them immediately (1:1 as before).
|
||||
TC-14 a branch cut at claim time (ensure_worktree on a not-yet-existing branch)
|
||||
is based on a FRESH origin/main that already contains the predecessor:
|
||||
git merge-base --is-ancestor <sha A> <base B> is true.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_branch.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 # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "branch.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_source", "db", raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-13
|
||||
async def _drive_start_pipeline(monkeypatch, gate_applies: bool):
|
||||
from src.webhooks import plane
|
||||
from src import plane_sync
|
||||
from src.projects import ProjectConfig
|
||||
|
||||
proj = ProjectConfig(
|
||||
plane_project_id="proj-uuid",
|
||||
repo="orchestrator",
|
||||
work_item_prefix="ORCH",
|
||||
name="orch",
|
||||
)
|
||||
monkeypatch.setattr(plane, "get_project_by_plane_id", lambda pid: proj)
|
||||
monkeypatch.setattr(plane, "_qg0_errors", lambda name, desc: [])
|
||||
monkeypatch.setattr(plane, "ensure_unique_work_item_id", lambda wid, repo: wid)
|
||||
monkeypatch.setattr(
|
||||
plane, "create_task_atomic",
|
||||
lambda *a, **k: ({"id": 1, "work_item_id": "ORCH-500"}, True),
|
||||
)
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_sequence_id", lambda *a, **k: 500)
|
||||
monkeypatch.setattr(plane_sync, "set_issue_analysis", lambda *a, **k: None)
|
||||
monkeypatch.setattr(plane_sync, "add_comment", lambda *a, **k: None)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", gate_applies, raising=False)
|
||||
|
||||
enq = []
|
||||
monkeypatch.setattr(plane, "enqueue_job", lambda *a, **k: (enq.append((a, k)) or 99))
|
||||
|
||||
branch_calls, docs_calls = [], []
|
||||
|
||||
async def _branch_spy(repo, branch):
|
||||
branch_calls.append((repo, branch))
|
||||
|
||||
async def _docs_spy(repo, branch, wi, name):
|
||||
docs_calls.append((repo, branch, wi, name))
|
||||
|
||||
monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy)
|
||||
monkeypatch.setattr(plane, "_create_initial_docs", _docs_spy)
|
||||
|
||||
data = {
|
||||
"id": "issue-uuid-1",
|
||||
"name": "Add serial gate",
|
||||
"description_stripped": "A sufficiently long description for QG-0 to pass.",
|
||||
"project": "proj-uuid",
|
||||
}
|
||||
await plane.start_pipeline(data, project_id="proj-uuid")
|
||||
return branch_calls, docs_calls, enq
|
||||
|
||||
|
||||
def test_branch_cut_deferred_when_gate_applies(monkeypatch):
|
||||
import asyncio
|
||||
branch_calls, docs_calls, enq = asyncio.run(
|
||||
_drive_start_pipeline(monkeypatch, gate_applies=True)
|
||||
)
|
||||
assert branch_calls == [], "branch must NOT be cut in start_pipeline while gated"
|
||||
assert docs_calls == [], "initial docs must NOT be created while gated"
|
||||
# The analyst-job is still enqueued (it waits in the queue without a branch).
|
||||
assert any(a[0] == "analyst" for a, k in enq), "analyst-job must still be enqueued"
|
||||
|
||||
|
||||
def test_branch_cut_immediate_when_kill_switch_off(monkeypatch):
|
||||
import asyncio
|
||||
branch_calls, docs_calls, enq = asyncio.run(
|
||||
_drive_start_pipeline(monkeypatch, gate_applies=False)
|
||||
)
|
||||
assert branch_calls, "with the gate off the branch is cut in start_pipeline (1:1)"
|
||||
assert docs_calls, "with the gate off initial docs are created in start_pipeline"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-14
|
||||
def _git(*args, cwd):
|
||||
env = {
|
||||
**os.environ,
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
}
|
||||
return subprocess.run(["git", *args], cwd=cwd, env=env,
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
|
||||
def test_deferred_branch_base_contains_predecessor(tmp_path, monkeypatch):
|
||||
"""A branch cut at claim time is based on a fresh origin/main with A's code."""
|
||||
from src import git_worktree
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
origin.mkdir()
|
||||
_git("init", "--bare", "-b", "main", str(origin), cwd=tmp_path)
|
||||
|
||||
repos_dir = tmp_path / "repos"
|
||||
wt_dir = tmp_path / "wt"
|
||||
repos_dir.mkdir()
|
||||
wt_dir.mkdir()
|
||||
repo = "orchestrator"
|
||||
clone = repos_dir / repo
|
||||
_git("clone", str(origin), str(clone), cwd=tmp_path)
|
||||
|
||||
# Predecessor A: commit on main + push to origin (== "A merged at its done").
|
||||
(clone / "a.txt").write_text("A's code\n")
|
||||
_git("add", "a.txt", cwd=clone)
|
||||
_git("commit", "-m", "task A", cwd=clone)
|
||||
_git("push", "origin", "main", cwd=clone)
|
||||
sha_a = _git("rev-parse", "HEAD", cwd=clone).stdout.strip()
|
||||
|
||||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir), raising=False)
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir), raising=False)
|
||||
|
||||
# Branch B does not exist yet -> ensure_worktree cuts it from fresh origin/main.
|
||||
wt = git_worktree.ensure_worktree(repo, "feature/ORCH-B")
|
||||
head_b = _git("rev-parse", "HEAD", cwd=wt).stdout.strip()
|
||||
|
||||
# AC-6: A's commit is an ancestor of B's base.
|
||||
r = subprocess.run(
|
||||
["git", "-C", wt, "merge-base", "--is-ancestor", sha_a, head_b],
|
||||
capture_output=True,
|
||||
)
|
||||
assert r.returncode == 0, "branch B base must contain predecessor A's commit (AC-6)"
|
||||
113
tests/test_serial_gate_e2e.py
Normal file
113
tests/test_serial_gate_e2e.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""ORCH-088 — serial gate end-to-end queue behaviour (real tmp SQLite).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-04 after A.stage='done' the waiting analyst-job of B is claimed (gate opens
|
||||
automatically — no manual action).
|
||||
TC-05 a queue of 3 tasks of one repo is processed strictly one-at-a-time, FIFO
|
||||
by jobs.id: while A is unfinished neither B nor C starts.
|
||||
TC-06 restart-safe: the active task is derived from the DB (tasks.repo +
|
||||
stage!='done'), not in-memory — re-reading state keeps the gate closed.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_serial_gate_e2e.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 config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "e2e.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", 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-04
|
||||
def test_next_starts_automatically_when_predecessor_done():
|
||||
a = _make_task("ORCH-301", stage="development")
|
||||
b = _make_task("ORCH-302", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
assert claim_next_job() is None, "B gated while A unfinished"
|
||||
|
||||
# A reaches done -> the gate opens on the NEXT claim tick, no manual action.
|
||||
_set_stage(a, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-05
|
||||
def test_three_tasks_processed_one_at_a_time_fifo():
|
||||
a = _make_task("ORCH-310", stage="analysis")
|
||||
b = _make_task("ORCH-311", stage="analysis")
|
||||
c = _make_task("ORCH-312", stage="analysis")
|
||||
job_a = enqueue_job("analyst", "orchestrator", "A", task_id=a)
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
job_c = enqueue_job("analyst", "orchestrator", "C", task_id=c)
|
||||
|
||||
# Only the FIFO-first task (A, lowest id) is claimable.
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_a
|
||||
assert claim_next_job() is None, "B and C must wait while A is unfinished"
|
||||
|
||||
# A runs through to done; now B (next) is claimable, C still waits.
|
||||
db.mark_job(job_a, "done")
|
||||
_set_stage(a, "done")
|
||||
claimed_b = claim_next_job()
|
||||
assert claimed_b is not None and claimed_b["id"] == job_b
|
||||
assert claim_next_job() is None, "C must wait while B is unfinished"
|
||||
|
||||
# B done -> C claimable last (strict FIFO order preserved).
|
||||
db.mark_job(job_b, "done")
|
||||
_set_stage(b, "done")
|
||||
claimed_c = claim_next_job()
|
||||
assert claimed_c is not None and claimed_c["id"] == job_c
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-06
|
||||
def test_restart_safe_active_task_from_db():
|
||||
a = _make_task("ORCH-320", stage="development")
|
||||
b = _make_task("ORCH-321", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert claim_next_job() is None
|
||||
|
||||
# Simulate a restart: there is NO in-memory state — the gate recomputes purely
|
||||
# from the DB. Re-running init_db (idempotent) + a fresh claim must still gate B.
|
||||
init_db()
|
||||
assert claim_next_job() is None, "after restart the gate is still closed (DB-derived)"
|
||||
|
||||
_set_stage(a, "done")
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user