fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1 (ORCH-060) #60
@@ -95,9 +95,14 @@ ORCH_IMAGE_FRESHNESS_REPOS=
|
||||
# GRACE_DEFAULT_S -> default "stuck" threshold on tasks.updated_at (seconds).
|
||||
# GRACE_OVERRIDES_JSON -> per-stage thresholds, e.g. {"development":300}; bad JSON -> default.
|
||||
# NOTIFY_UNBLOCK -> send a Telegram message when a stuck task is unblocked.
|
||||
# SKIP_BLOCKED_ENABLED -> ORCH-060 F-1 Guard 2: skip reconciling issues a human moved
|
||||
# to Blocked / Needs Input (per-candidate Plane state lookup).
|
||||
# false mutes ONLY the networked Guard 2; Guard 1 (escalated by
|
||||
# developer retries, local+deterministic) is always active.
|
||||
ORCH_RECONCILE_ENABLED=true
|
||||
ORCH_RECONCILE_PLANE_ENABLED=true
|
||||
ORCH_RECONCILE_INTERVAL_S=120
|
||||
ORCH_RECONCILE_GRACE_DEFAULT_S=600
|
||||
ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
|
||||
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
|
||||
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||
|
||||
### Fixed
|
||||
- **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)** — `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053).
|
||||
- **Re-deploy после отката больше не зависает на `deploy`; `.env.example` дополнен** (ORCH-036, review-fix): sentinel-маркеры самодеплоя (`approve-requested`/`initiated`/`result`) ключуются по стабильному `work_item_id`, поэтому при FAILED-деплое и откате БАГ-8 (`deploy → development`) они оставались на диске — после фикса developer-ом и повторного захода задачи на `deploy` Фаза B по idempotency-guard видела STALE `initiated` и становилась no-op: detached-хук не перезапускался, finalizer не ставился, задача висела на `deploy` навсегда (нарушался retry-контракт стадии, AC-4/AC-10; устаревший `result` к тому же был бы перечитан новым finalizer'ом). Добавлен `self_deploy.clear_state(repo, work_item_id)` (never-raise, idempotent, рекурсивное удаление `<repos_dir>/.deploy-state-<repo>/<wi>/`), вызывается в ветке БАГ-8-отката `check_deploy_status` FAILED (`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`) — каждый новый прод-деплой-проход стартует с чистого состояния. Отдельно: канонический `.env.example` (CLAUDE.md правило №8, ТЗ §2.6) дополнен полным блоком новых дескрипторов `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*` (плейсхолдеры, секреты не коммитятся) по образцу merge-gate ORCH-043. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` / `_parse_deploy_status` / БАГ-8 / merge-gate не тронуты. Тесты: `tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`, `tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`.
|
||||
- **Контейнер и агенты бегут под uid хоста (1000:1000), не root** (ORCH-040): оба сервиса в `docker-compose.yml` (`orchestrator`, `orchestrator-staging`) получили `user: "1000:1000"` (slin) — устраняет корень проблемы, при которой Claude-CLI агенты, запускаемые через `subprocess.Popen` внутри root-контейнера, создавали все артефакты конвейера (git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) с владельцем `root:root` на хосте, из-за чего `git pull`/`git reset` под slin падали с `insufficient permission for adding an object` и каждый деплой требовал ручного `chown`. Теперь файлы сразу `slin:slin`. Доступ к docker.sock сохранён через `group_add: ["999"]` (МИНА 1 — НЕ удалена). SSH-маунт приведён к единому HOME агента: target `/root/.ssh` → `/home/slin/.ssh` (`/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`), синхронно с `HOME=/home/slin`, который launcher форсит в env Popen и git_env — устранён скрытый рассинхрон SSH-маунта с форсимым HOME. `src/agents/launcher.py` и `Dockerfile` НЕ менялись (numeric uid работает без записи в `/etc/passwd`; `safe.directory '*'` уже покрывает git над bind-mount). Требует host-prerequisites Owner (P-1…P-4, вне кода): блокер P-1 — `chown -R 1000:1000 /home/slin/.claude` для доступа uid 1000 к claude creds (иначе preflight заворачивает конвейер); прод-рестарт self — только в окно тишины (общий инстанс с enduro-trails), страховка — staging-гейт (adr-0003). ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`, глобальный `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`; INFRA.md обновлён (рантайм-uid, volumes/SSH target, host-prerequisites). Тесты: `tests/test_orch040_compose.py`.
|
||||
- **Staging-чек B6 читает реестр из окружения работающего staging-инстанса** (ORCH-048): блок B6 «Registry: sandbox present, prod ET/ORCH absent» в `scripts/staging_check.py` давал **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции — единственный чек suite, который не ходил к инстансу по HTTP, а импортировал `src.projects` локально через host-path хак `sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`, строя реестр из `ORCH_PROJECTS_JSON` **process-env запускающего процесса**. При фактическом запуске деплоером с хоста переменная не задана → дефолт `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL → лишний откат `deploy-staging → development`. Решение (вариант «в», ADR-001): host-path хак удалён; suite канонически запускается ВНУТРИ контейнера `orchestrator-staging` через `docker exec … python3 /repos/orchestrator/scripts/staging_check.py` (`scripts/` доступен только через bind-mount, `import src.projects` резолвится через `PYTHONPATH=/app` из кода контейнера, env — `.env.staging`) → B6 читает реестр именно работающего инстанса, без HTTP-bootstrap и «курицы-яйца». Логика вердикта вынесена в чистую `_evaluate_b6(known) -> (passed, detail)` (инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`, формат detail сохранён) + `_known_project_ids_from_registry()` / `_run_b6()` с детерминированным FAIL при недоступности источника (не ложный PASS, не необработанное исключение). Синхронно обновлены `.openclaw/agents/deployer.md` (команда стадии через `docker exec`) и `docs/operations/STAGING_CHECK.md`. `src/projects.py`, `.env*` и прочие чеки A/B4/B5/C не тронуты; реестр `QG_CHECKS` и `check_staging_status` (ADR-0003) не менялись. ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`. Тесты: `tests/test_staging_check_b6.py`.
|
||||
|
||||
@@ -135,6 +135,7 @@ uvicorn src.main:app --reload --port 8500
|
||||
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | Порог «застряла» по `tasks.updated_at`, сек | `600` |
|
||||
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` |
|
||||
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` |
|
||||
| `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` |
|
||||
|
||||
## Очередь задач (ORCH-1 / F-2b)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
|
||||
- **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 не трогает (человеческий гейт). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
||||
- **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`.
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||
|
||||
@@ -118,6 +118,13 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
|
||||
`age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка канонического QG;
|
||||
зелёный → `stage_engine.advance_stage(..., finished_agent=None)`; красный →
|
||||
тишина (спам нотификаций структурно невозможен). `analysis` не реконсилируется.
|
||||
**Skip escalated / Blocked / Needs-Input (ORCH-060):** ДО оценки гейта F-1
|
||||
пропускает (молча, без advance/нотификаций) задачи, которые ждут человека —
|
||||
(1) исчерпавшие лимит developer-ретраев (`developer_retry_count(task_id) >=
|
||||
MAX_DEVELOPER_RETRIES`, детерминированно, без сети — закрывает bounce-петлю
|
||||
ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A —
|
||||
запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард
|
||||
retry-count проверяется первым (дёшево, локальный SQL).
|
||||
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
|
||||
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
|
||||
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
|
||||
@@ -194,4 +201,4 @@ never-raise на единицу работы; тишина при синхрон
|
||||
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.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-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile).*
|
||||
*Актуально на 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).*
|
||||
|
||||
@@ -61,6 +61,14 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти
|
||||
(`reconcile_plane_enabled` гасит только F-2); reconciler не рестартит/не роняет
|
||||
прод-контейнер. БД-схема и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются.
|
||||
|
||||
## Уточнения
|
||||
- **ORCH-060** (`docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`):
|
||||
F-1 (`_reconcile_gate_task`) приобретает два пред-гарда ДО оценки гейта —
|
||||
пропускает escalated (`developer_retry_count ≥ MAX_DEVELOPER_RETRIES`,
|
||||
детерминированно) и Blocked/Needs-Input (Вариант A, Plane API, без миграции)
|
||||
задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise,
|
||||
тишина при пропуске).
|
||||
|
||||
## Связи
|
||||
adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный
|
||||
гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра
|
||||
|
||||
7
docs/work-items/ORCH-060/00-business-request.md
Normal file
7
docs/work-items/ORCH-060/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Reconciler не должен трогать escalated / max-retries задачи
|
||||
|
||||
Work Item ID: ORCH-060
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
90
docs/work-items/ORCH-060/01-brd.md
Normal file
90
docs/work-items/ORCH-060/01-brd.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# BRD: Reconciler не должен трогать escalated / max-retries задачи
|
||||
|
||||
Work Item ID: ORCH-060
|
||||
Стадия: analysis → architecture
|
||||
Связано: ORCH-053 (reconciler), ORCH-046 (retry-счётчик), ORCH-047 (BLOCKED-вердикт)
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
ORCH-053 ввёл фоновый reconciler (`src/reconciler.py`) — sweeper, доигрывающий
|
||||
пропущенные webhook-переходы. Слой F-1 (`reconcile_gate_once` →
|
||||
`_reconcile_gate_task`) для каждой не-терминальной задачи (`stage != 'done'`) без
|
||||
активного job и старше grace делает read-only пред-оценку канонического QG; если
|
||||
гейт зелёный → `advance_if_gate_passed` → `advance_stage(..., finished_agent=None)`.
|
||||
|
||||
**Дефект.** Задача, исчерпавшая лимит developer-ретраев
|
||||
(`_developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES = 3`), **escalated** —
|
||||
но эскалация в обработчиках Gitea (`src/webhooks/gitea.py:280` для CI-failure,
|
||||
`:371` для review REQUEST_CHANGES) выполняет ТОЛЬКО `notify_error(...)`:
|
||||
|
||||
- стадия НЕ меняется (остаётся `development`);
|
||||
- терминального маркера в БД нет (нет `blocked`-флага в таблице `tasks`);
|
||||
- активного job нет.
|
||||
|
||||
Для reconciler такая задача неотличима от «застрявшей из-за потерянного webhook».
|
||||
Если CI к этому моменту зелёный (типичный кейс: разработчик починил CI, но reviewer
|
||||
продолжал слать REQUEST_CHANGES → ушли в лимит), F-1 каждые `reconcile_interval_s`
|
||||
(120 с) видит зелёный `check_ci_green` и **разблокирует** задачу `development → review`.
|
||||
Reviewer снова REQUEST_CHANGES → откат на `development` → снова эскалация (стадия
|
||||
не меняется). Следующий тик — снова разблокировка. Бесконечный цикл.
|
||||
|
||||
**Реальный инцидент (наблюдение 06–07.06.2026).** ET-013 разблокирована
|
||||
reconciler'ом **10 раз за ночь**, в итоге всё равно escalated — бесполезный поллинг
|
||||
каждые 2 минуты, лишние запуски агентов (токены, деньги), шум в Telegram
|
||||
(`reconcile_notify_unblock`), нагрузка на конвейер общего инстанса (self-hosting:
|
||||
один инстанс обслуживает ORCH + enduro-trails).
|
||||
|
||||
Симметричный риск: задача, которую человек/агент явно перевёл в Plane-статус
|
||||
**Blocked** или **Needs Input** (ручной гейт), не должна автоматически
|
||||
разблокироваться reconciler'ом до вмешательства человека.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Reconciler (F-1) обязан **пропускать** (не трогать) задачи, которые:
|
||||
1. исчерпали лимит developer-ретраев (`_developer_retry_count >= MAX_DEVELOPER_RETRIES`), и/или
|
||||
2. находятся в явном «человеческом»/терминальном Plane-статусе **Blocked** / **Needs Input**.
|
||||
|
||||
Такие задачи ждут ручного вмешательства; автоматический sweeper их игнорирует.
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
|
||||
- **Owner проекта** — прекращение «фантомной» активности и шума по escalated-задачам.
|
||||
- **Другие проекты на инстансе (enduro-trails)** — снижение паразитной нагрузки общей очереди.
|
||||
- **Агенты-разработчики оркестратора** — корректная семантика терминального состояния.
|
||||
|
||||
## 4. Объём (Scope)
|
||||
|
||||
### Входит
|
||||
- Гард в F-1 (`_reconcile_gate_task` / `advance_if_gate_passed`), который ДО
|
||||
оценки гейта и вызова `advance_stage` пропускает escalated-задачи
|
||||
(retry-count >= лимит) — детерминированно, без сети.
|
||||
- Гард, пропускающий задачи в Plane-статусе Blocked / Needs Input.
|
||||
- Тесты (unit) на оба условия + регресс happy-path и отсутствия спама/нотификаций.
|
||||
- Обновление документации: `docs/architecture/README.md` (описание F-1),
|
||||
per-work-item ADR, `CHANGELOG.md`.
|
||||
|
||||
### Не входит
|
||||
- Изменение порога `MAX_DEVELOPER_RETRIES` или логики самой эскалации в `gitea.py`.
|
||||
- Изменение F-2 plane-side по существу (F-2 уже реагирует только на
|
||||
in_progress/approved/rejected, то есть Blocked/Needs Input им не доигрываются —
|
||||
достаточно регресс-теста, фиксирующего это поведение).
|
||||
- Реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схема прочих стадий.
|
||||
|
||||
## 5. Допущения и ограничения
|
||||
|
||||
- **Инвариант reconciler (ORCH-053):** схема БД и реестры не меняются. Решение
|
||||
должно либо обойтись без миграции, либо архитектор обязан явно обосновать
|
||||
необходимость нового столбца как терминального маркера.
|
||||
- **Never-raise:** гард не должен ломать тик; любая ошибка вычисления условия →
|
||||
безопасный фоллбэк (не трогать задачу — консервативно).
|
||||
- **self-hosting:** нельзя ронять/рестартить прод-контейнер; изменение — чисто
|
||||
логика sweeper'а, деплой через staging (8501) по канону.
|
||||
- Источник истины по retry — `agent_runs` (как у `_developer_retry_count`).
|
||||
|
||||
## 6. Критерий успеха (бизнес)
|
||||
|
||||
После выката на конкретной escalated-задаче (как ET-013): за ночь — **0**
|
||||
строк `reconciler: <wi> ... разблокирована`, **0** повторных запусков агентов,
|
||||
**0** Telegram-нотификаций разблокировки; задача спокойно ждёт человека в
|
||||
`development`/Blocked. При этом штатные «честно застрявшие» задачи
|
||||
(retry < лимита, не Blocked) reconciler по-прежнему доигрывает.
|
||||
113
docs/work-items/ORCH-060/02-trz.md
Normal file
113
docs/work-items/ORCH-060/02-trz.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# ТЗ: Reconciler пропускает escalated / max-retries / blocked-needs-input задачи
|
||||
|
||||
Work Item ID: ORCH-060
|
||||
Стадия: analysis → architecture (архитектор фиксирует механику в ADR)
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче |
|
||||
|--------|---------------|
|
||||
| `src/reconciler.py` | **Основное изменение.** F-1: `Reconciler._reconcile_gate_task` — добавить пред-проверки (escalated / blocked / needs-input) ДО `advance_if_gate_passed`. |
|
||||
| `src/stage_engine.py` | Источник `MAX_DEVELOPER_RETRIES` (=3) и `_developer_retry_count(task_id)`. Кандидат на промоут приватного хелпера в переиспользуемый (решает архитектор). |
|
||||
| `src/db.py` | Чтение состояния задачи (`get_active_tasks_for_reconcile` уже отдаёт строки `tasks`); возможный новый read-helper для retry-count, если решено не импортировать приватный из stage_engine. |
|
||||
| `src/plane_sync.py` | Маппинг Plane-статусов (`PLANE_STATES`, `get_project_states`): `blocked`, `needs_input`. Источник для проверки «человеческого» статуса, если архитектор выберет проверку через Plane API. |
|
||||
| `src/webhooks/gitea.py` | НЕ меняется (только справочно: точки эскалации `:280`, `:371`). |
|
||||
|
||||
## 2. Требуемое поведение (контракт F-1)
|
||||
|
||||
`Reconciler._reconcile_gate_task(task)` ДО вызова `advance_if_gate_passed(...)`
|
||||
обязан вернуться (пропустить задачу, ничего не делая, не инкрементируя
|
||||
`unblocked_total`, не слать нотификации), если выполнено ЛЮБОЕ из условий:
|
||||
|
||||
1. **Escalated по ретраям (обязательно, детерминированно, без сети):**
|
||||
`developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`.
|
||||
- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine` (НЕ хардкодить число).
|
||||
- Источник счётчика — тот же запрос, что в `_developer_retry_count`:
|
||||
`SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'`.
|
||||
|
||||
2. **Явный человеческий/терминальный Plane-статус:** issue в состоянии
|
||||
**Blocked** или **Needs Input**.
|
||||
|
||||
Порядок: проверки добавляются в `_reconcile_gate_task` ПОСЛЕ существующих гардов
|
||||
(`stage=='analysis'` carve-out, `get_qg_for_stage is None`, `has_active_job_for_task`,
|
||||
grace) и ДО `advance_if_gate_passed`. Условие (1) — дешёвое (локальный SQL) —
|
||||
проверять раньше условия (2), если (2) требует сети.
|
||||
|
||||
## 3. Механика проверки blocked/needs-input (выбор — за архитектором, ADR)
|
||||
|
||||
В таблице `tasks` НЕТ столбца статуса (`stage` всегда `development` у escalated).
|
||||
Архитектор выбирает и обосновывает один из вариантов; требования к каждому:
|
||||
|
||||
- **Вариант A — проверка через Plane API (без миграции, предпочтительно по
|
||||
инварианту ORCH-053 «схема не меняется»):** для кандидата F-1 запросить текущее
|
||||
состояние issue (per-project `get_project_states` → сверка с `blocked`/`needs_input`).
|
||||
Допустимо, т.к. F-1 уже делает сетевой вызов в гейте (`check_ci_green`), а
|
||||
кандидатов после grace+no-active-job немного. Обязателен never-raise: ошибка
|
||||
запроса → консервативно НЕ трогать задачу (skip), либо явно обоснованный фоллбэк.
|
||||
- **Вариант B — локальный терминальный маркер в БД:** идемпотентная миграция
|
||||
(`tasks.blocked`/`tasks.reconcile_skip`), выставляется в точках `set_issue_blocked`/
|
||||
`set_issue_needs_input` и в точках эскалации `gitea.py`. Требует обоснования
|
||||
нарушения инварианта «схема reconciler не меняется» и затрагивает больше точек.
|
||||
|
||||
> Рекомендация аналитика: условие (1) полностью закрывает зафиксированный инцидент
|
||||
> (ET-013 = escalated = max retries) детерминированно и без сети — оно
|
||||
> обязательно к реализации. Условие (2) — защита от автоперекрытия ручного гейта;
|
||||
> минимально-инвазивный путь — Вариант A. Архитектор вправе ограничить (2)
|
||||
> Вариантом A либо обосновать B.
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
Нет. Эндпоинты не добавляются и не меняются. Снимок `GET /queue` (блок `reconcile`)
|
||||
по содержимому не меняется; опционально архитектор может добавить best-effort
|
||||
счётчик `skipped_escalated` (необязательно, вне scope AC).
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
По умолчанию — **нет** (Вариант A). При выборе Варианта B — идемпотентная
|
||||
ALTER-миграция через `_ensure_column` (как остальные в `db.init_db`),
|
||||
restart-safe, безопасная на живой прод-БД; обязательна явная мотивация в ADR.
|
||||
|
||||
## 6. Требования к QG checks
|
||||
|
||||
Нет новых QG. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. Гард —
|
||||
ВНЕ гейта: он решает, ЗАПУСКАТЬ ли пред-оценку гейта вообще, а не меняет вердикт
|
||||
гейта.
|
||||
|
||||
## 7. Инварианты, которые нельзя нарушить
|
||||
|
||||
- **Never-raise** на единицу работы (per-task `try/except` в `reconcile_gate_once`
|
||||
сохраняется; новая логика не должна бросать наружу).
|
||||
- **Тишина при пропуске:** пропущенная задача не инкрементирует `unblocked_total`,
|
||||
не пишет лог `разблокирована`, не шлёт Telegram.
|
||||
- **Регресс F-1 happy-path:** задача с retry < лимита и не-Blocked/Needs-Input при
|
||||
зелёном гейте по-прежнему доигрывается (`advance_stage` вызывается).
|
||||
- **F-2** по существу не меняется: Blocked/Needs Input не входят в
|
||||
{in_progress, approved, rejected} → не доигрываются (зафиксировать регресс-тестом).
|
||||
- `analysis` carve-out F-1 сохраняется.
|
||||
- Kill-switch'и (`reconcile_enabled`, `reconcile_plane_enabled`) работают как прежде.
|
||||
|
||||
## 8. Артефакты pipeline, которые должны быть созданы/обновлены
|
||||
|
||||
- `docs/work-items/ORCH-060/06-adr/ADR-001-*.md` — решение по механике (2) (A vs B).
|
||||
- `docs/architecture/README.md` — дополнить описание F-1 («skip escalated /
|
||||
blocked / needs-input»).
|
||||
- `CHANGELOG.md` — запись `fix(reconciler): ...`.
|
||||
- Тесты — `tests/test_reconciler.py` (расширение).
|
||||
- Обновить footer `docs/architecture/README.md` (статус ORCH-060).
|
||||
|
||||
## 9. Точки изменения кода (конкретно)
|
||||
|
||||
1. `src/reconciler.py`, `_reconcile_gate_task`: после grace-проверки и до
|
||||
`advance_if_gate_passed` вставить:
|
||||
```python
|
||||
# ORCH-060: escalated tasks (max developer retries reached) are terminal —
|
||||
# they wait for a human, not the sweeper. Skip deterministically (no network).
|
||||
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
|
||||
return
|
||||
# ORCH-060: respect an explicit human gate (Blocked / Needs Input).
|
||||
if self._is_blocked_or_needs_input(task): # mechanism per ADR (Variant A/B)
|
||||
return
|
||||
```
|
||||
2. `src/reconciler.py`: импорт `MAX_DEVELOPER_RETRIES` (и retry-count хелпера) из
|
||||
`stage_engine` (или новый read-helper в `db.py`).
|
||||
3. Хелпер проверки Plane-статуса (`_is_blocked_or_needs_input`) — never-raise.
|
||||
124
docs/work-items/ORCH-060/03-acceptance-criteria.md
Normal file
124
docs/work-items/ORCH-060/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Критерии приёмки: ORCH-060
|
||||
|
||||
Work Item ID: ORCH-060
|
||||
|
||||
Формат: каждый критерий — Дано / Когда / Тогда, с однозначным PASS/FAIL.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Escalated-задача (retry == лимит) не разблокируется (главный кейс ET-013)
|
||||
|
||||
- **Дано:** задача на `stage='development'`, без активного job, `age >= grace`,
|
||||
`check_ci_green` зелёный; в `agent_runs` ровно `MAX_DEVELOPER_RETRIES` (=3)
|
||||
записей `agent='developer'`.
|
||||
- **Когда:** выполняется `Reconciler.reconcile_gate_once()`.
|
||||
- **Тогда:** стадия остаётся `development`; `advance_stage`/`advance_if_gate_passed`
|
||||
не приводит к смене стадии; `unblocked_total == 0`; новый developer/reviewer job
|
||||
не создаётся.
|
||||
- **PASS:** стадия не изменилась И `unblocked_total == 0` И нет новых job.
|
||||
- **FAIL:** стадия стала `review` / появился новый job / `unblocked_total > 0`.
|
||||
|
||||
## AC-2 — Граница: retry > лимита тоже пропускается
|
||||
|
||||
- **Дано:** то же, но developer-записей `> MAX_DEVELOPER_RETRIES` (например 4–5).
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** задача пропущена (как AC-1).
|
||||
- **PASS / FAIL:** как AC-1.
|
||||
|
||||
## AC-3 — Регресс happy-path: retry < лимита по-прежнему доигрывается
|
||||
|
||||
- **Дано:** `development`, без активного job, `age >= grace`, `check_ci_green`
|
||||
зелёный; developer-записей `< MAX_DEVELOPER_RETRIES` (например 0, 1 или 2).
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** задача доигрывается `development → review`; `unblocked_total == 1`;
|
||||
enqueue следующего агента происходит как раньше.
|
||||
- **PASS:** стадия стала `review` И `unblocked_total == 1`.
|
||||
- **FAIL:** задача пропущена / стадия не изменилась.
|
||||
|
||||
## AC-4 — Граница ровно на лимите (==3) → skip, на (лимит−1) → advance
|
||||
|
||||
- **Дано:** две задачи-близнеца, идентичные кроме числа developer-записей:
|
||||
одна с `MAX_DEVELOPER_RETRIES`, другая с `MAX_DEVELOPER_RETRIES − 1`.
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** первая пропущена (skip), вторая доиграна (advance).
|
||||
- **PASS:** ровно одна из двух доиграна (та, что `−1`).
|
||||
- **FAIL:** обе доиграны / обе пропущены / доиграна задача на лимите.
|
||||
|
||||
## AC-5 — Plane-статус Blocked → пропуск
|
||||
|
||||
- **Дано:** задача-кандидат F-1 (stage не-терминальный, без активного job,
|
||||
`age >= grace`, гейт зелёный), у которой текущий Plane-статус issue = **Blocked**;
|
||||
retry < лимита (чтобы изолировать именно этот гард).
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** задача пропущена; стадия не меняется; `unblocked_total == 0`.
|
||||
- **PASS:** стадия не изменилась И `unblocked_total == 0`.
|
||||
- **FAIL:** задача доиграна.
|
||||
|
||||
## AC-6 — Plane-статус Needs Input → пропуск
|
||||
|
||||
- **Дано:** как AC-5, но Plane-статус = **Needs Input**.
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** задача пропущена (как AC-5).
|
||||
- **PASS / FAIL:** как AC-5.
|
||||
|
||||
## AC-7 — Тишина при пропуске (no spam)
|
||||
|
||||
- **Дано:** escalated-задача (как AC-1).
|
||||
- **Когда:** `reconcile_gate_once()` (один или несколько тиков).
|
||||
- **Тогда:** НЕ вызывается `_note_unblock`; нет лог-строки `... разблокирована`;
|
||||
нет `send_telegram`; нет `notify_qg_failure` (пропуск — раньше оценки гейта).
|
||||
- **PASS:** ни одна из перечисленных нотификаций не вызвана.
|
||||
- **FAIL:** вызвана любая нотификация.
|
||||
|
||||
## AC-8 — Никакого сетевого вызова гейта на escalated-задаче
|
||||
|
||||
- **Дано:** escalated-задача (как AC-1) с замоканным `check_ci_green`.
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** `check_ci_green` (через `advance_if_gate_passed`/`_run_qg`) НЕ
|
||||
вызывается для этой задачи — пропуск происходит раньше.
|
||||
- **PASS:** мок гейта не вызван.
|
||||
- **FAIL:** мок гейта вызван.
|
||||
|
||||
## AC-9 — F-2 не доигрывает Blocked/Needs Input (регресс)
|
||||
|
||||
- **Дано:** issue в Plane-статусе Blocked или Needs Input (не входит в
|
||||
{in_progress, approved, rejected}).
|
||||
- **Когда:** `reconcile_plane_once()`.
|
||||
- **Тогда:** ни `handle_status_start`, ни `handle_verdict` не вызываются для
|
||||
этого issue; `unblocked_total == 0`.
|
||||
- **PASS:** обработчики не вызваны.
|
||||
- **FAIL:** вызван любой обработчик.
|
||||
|
||||
## AC-10 — Never-raise: ошибка проверки статуса не ломает тик
|
||||
|
||||
- **Дано:** проверка blocked/needs-input (Plane API в Варианте A) бросает
|
||||
исключение для одной задачи; в выборке есть ещё одна валидная задача.
|
||||
- **Когда:** `reconcile_gate_once()`.
|
||||
- **Тогда:** тик не падает; сбойная задача консервативно НЕ трогается (skip);
|
||||
остальные обрабатываются.
|
||||
- **PASS:** исключение изолировано, остальные задачи обработаны.
|
||||
- **FAIL:** исключение всплыло из `reconcile_gate_once`.
|
||||
|
||||
## AC-11 — Лимит не хардкодится
|
||||
|
||||
- **Дано:** код F-1-гарда.
|
||||
- **Тогда:** используется `stage_engine.MAX_DEVELOPER_RETRIES`, а не литерал `3`.
|
||||
- **PASS:** граница берётся из константы.
|
||||
- **FAIL:** в reconciler.py появился магический `3`.
|
||||
|
||||
## AC-12 — Документация обновлена (golden source)
|
||||
|
||||
- **Дано:** PR задачи.
|
||||
- **Тогда:** обновлены `docs/architecture/README.md` (описание F-1 с новым skip),
|
||||
`CHANGELOG.md`, создан `06-adr/ADR-001-*.md`.
|
||||
- **PASS:** все три артефакта обновлены/созданы в этом же PR.
|
||||
- **FAIL:** любой отсутствует (reviewer → REQUEST_CHANGES).
|
||||
|
||||
## AC-13 — Регресс существующих тестов reconciler
|
||||
|
||||
- **Дано:** существующий `tests/test_reconciler.py` (ORCH-053).
|
||||
- **Когда:** `pytest tests/test_reconciler.py -q`.
|
||||
- **Тогда:** все прежние тесты зелёные (поведение happy-path/analysis/kill-switch
|
||||
не сломано).
|
||||
- **PASS:** 0 регрессий.
|
||||
- **FAIL:** любой ранее зелёный тест упал.
|
||||
82
docs/work-items/ORCH-060/04-test-plan.yaml
Normal file
82
docs/work-items/ORCH-060/04-test-plan.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
work_item: ORCH-060
|
||||
description: >
|
||||
Reconciler F-1 пропускает escalated (retry >= MAX_DEVELOPER_RETRIES) и
|
||||
явно-blocked / needs-input задачи; happy-path и no-spam сохранены.
|
||||
Конвенции test-фикстур — как в существующем tests/test_reconciler.py
|
||||
(изолированная sqlite-БД, моки Plane/Telegram/gate). Хелпер _make_task
|
||||
вставляет задачу; developer-ретраи моделируются вставкой N строк в agent_runs
|
||||
(agent='developer'); зелёный CI — через _green_ci(monkeypatch).
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "AC-1: escalated dev-задача (ровно MAX_DEVELOPER_RETRIES developer-ранов) при зелёном CI НЕ разблокируется — стадия остаётся development, unblocked_total==0, новых job нет"
|
||||
module: tests/test_reconciler.py
|
||||
setup: "_make_task('development', age_s=grace+60); insert MAX_DEVELOPER_RETRIES rows agent_runs(agent='developer'); _green_ci()"
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "AC-2: developer-ранов > MAX_DEVELOPER_RETRIES (4–5) → также skip"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "AC-3 (регресс happy-path): developer-ранов < MAX (0/1/2) при зелёном CI → задача доигрывается development->review, unblocked_total==1"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "AC-4: граница — задача с ровно MAX пропущена, задача с MAX-1 доиграна (ровно одна advance)"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "AC-5: задача в Plane-статусе Blocked (retry<лимита) пропущена — стадия не меняется, unblocked_total==0 (мок проверки статуса возвращает Blocked)"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "AC-6: задача в Plane-статусе Needs Input (retry<лимита) пропущена"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "AC-7 (no spam): на escalated-задаче не вызваны _note_unblock / send_telegram / notify_qg_failure; нет лог-строки 'разблокирована'"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "AC-8: на escalated-задаче мок check_ci_green НЕ вызван (skip раньше пред-оценки гейта)"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "AC-9 (регресс F-2): issue в Blocked/Needs Input не передаётся ни в handle_status_start, ни в handle_verdict при reconcile_plane_once; unblocked_total==0"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "AC-10 (never-raise): проверка blocked/needs-input бросает исключение на одной задаче → тик не падает, сбойная skip, валидная соседняя обработана"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "AC-11: граница берётся из stage_engine.MAX_DEVELOPER_RETRIES — тест с monkeypatch значения константы меняет точку отсечения (нет хардкода 3)"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "AC-13 (регресс): полный прогон tests/test_reconciler.py (ORCH-053 кейсы) — все прежние тесты зелёные"
|
||||
module: tests/test_reconciler.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,161 @@
|
||||
# ADR-001: Reconciler (F-1) пропускает escalated / Blocked / Needs-Input задачи
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-07
|
||||
- **Задача:** ORCH-060
|
||||
- **Стадия:** architecture
|
||||
- **Связано:** adr-0007 (reconciler, ORCH-053) — уточняет контракт F-1;
|
||||
ORCH-046 (retry-счётчик), ORCH-047 (BLOCKED-вердикт)
|
||||
|
||||
## Контекст
|
||||
|
||||
ORCH-053 ввёл F-1 (`Reconciler._reconcile_gate_task`): для каждой не-терминальной
|
||||
задачи без активного job и старше grace делается read-only пред-оценка
|
||||
канонического QG; зелёный → `advance_if_gate_passed` →
|
||||
`advance_stage(..., finished_agent=None)`.
|
||||
|
||||
**Дефект (инцидент ET-013, 06–07.06.2026).** Задача, исчерпавшая лимит
|
||||
developer-ретраев (`_developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES = 3`),
|
||||
**escalated** в обработчиках `gitea.py` (`:280` CI-failure, `:371` review
|
||||
REQUEST_CHANGES) выполняет ТОЛЬКО `notify_error(...)`:
|
||||
|
||||
- стадия НЕ меняется (остаётся `development`);
|
||||
- терминального маркера в БД нет (нет столбца статуса в `tasks`);
|
||||
- активного job нет.
|
||||
|
||||
Для F-1 такая задача **неотличима** от «застрявшей из-за потерянного webhook».
|
||||
Если CI зелёный (типовой кейс: dev починил CI, но reviewer слал REQUEST_CHANGES
|
||||
до лимита), каждые `reconcile_interval_s` (120с) F-1 видит зелёный `check_ci_green`
|
||||
и разблокирует `development → review` → reviewer снова REQUEST_CHANGES → откат →
|
||||
снова эскалация (стадия не меняется) → следующий тик снова разблокирует.
|
||||
**Бесконечный цикл:** ET-013 разблокирована 10 раз за ночь, лишние запуски агентов
|
||||
(токены/деньги), спам в Telegram, паразитная нагрузка общего self-hosting-инстанса.
|
||||
|
||||
Симметричный риск: задачу, которую человек явно перевёл в Plane-статус **Blocked**
|
||||
/ **Needs Input** (ручной гейт), sweeper не должен авторазблокировать до
|
||||
вмешательства человека.
|
||||
|
||||
## Решение
|
||||
|
||||
В `_reconcile_gate_task` ПОСЛЕ существующих гардов (`stage=='analysis'` carve-out,
|
||||
`get_qg_for_stage is None`, `has_active_job_for_task`, grace) и ДО
|
||||
`advance_if_gate_passed` добавляются два пред-гарда. Любой срабатывает → ранний
|
||||
`return`: задача пропущена, гейт НЕ оценивается, `unblocked_total` не растёт,
|
||||
нотификаций нет.
|
||||
|
||||
### Гард 1 — escalated по ретраям (детерминированный, без сети) — **обязателен**
|
||||
|
||||
```python
|
||||
# ORCH-060: escalated tasks (max developer retries reached) are terminal —
|
||||
# they wait for a human, not the sweeper. Deterministic, no network.
|
||||
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
|
||||
return
|
||||
```
|
||||
|
||||
- Источник истины по retry — `agent_runs` (как у `_developer_retry_count`):
|
||||
`SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'`.
|
||||
- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine` — **не хардкодить `3`**
|
||||
(AC-11).
|
||||
- Граница `>=` (на лимите — skip, на `лимит−1` — advance; AC-4).
|
||||
|
||||
**Промоут хелпера.** `stage_engine._developer_retry_count` повышается до публичного
|
||||
`developer_retry_count` (приватное имя сохраняется как алиас для существующих
|
||||
внутренних call-sites). Reconciler импортирует
|
||||
`MAX_DEVELOPER_RETRIES, developer_retry_count` из `stage_engine`. SQL **не
|
||||
дублируется** в `db.py` — единый источник истины по подсчёту ретраев.
|
||||
|
||||
### Гард 2 — явный человеческий Plane-статус (Blocked / Needs Input) — **Вариант A**
|
||||
|
||||
```python
|
||||
# ORCH-060: respect an explicit human gate (Blocked / Needs Input).
|
||||
if self._is_blocked_or_needs_input(task):
|
||||
return
|
||||
```
|
||||
|
||||
Механика — **Вариант A (запрос Plane API, без миграции схемы):**
|
||||
|
||||
1. Новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id)
|
||||
-> str | None` — GET issue-detail (тот же endpoint/headers, что
|
||||
`fetch_issue_sequence_id` / `fetch_issue_fields`), возвращает uuid текущего
|
||||
`state`; любая ошибка/отсутствие поля → `None`.
|
||||
2. `Reconciler._is_blocked_or_needs_input(task)`:
|
||||
- `repo → ProjectConfig` через `projects.get_project_by_repo(task['repo'])`;
|
||||
- `pid = proj.plane_project_id`; `states = get_project_states(pid)` (кэш per-project);
|
||||
- `cur = fetch_issue_state(task['plane_id' | 'plane_issue_id'], pid)`;
|
||||
- вернуть `cur in {states['blocked'], states['needs_input']}`.
|
||||
- **Never-raise → консервативный фоллбэк:** любая ошибка/`None`/нерезолвленный
|
||||
проект → трактуем как «возможно заблокировано» → возвращаем `True` (skip).
|
||||
Не-разблокировать безопаснее, чем разблокировать (AC-10).
|
||||
|
||||
**Порядок гардов:** Гард 1 (локальный SQL, дёшево) — ПЕРВЫМ; Гард 2 (сеть) —
|
||||
вторым. Для зафиксированного инцидента (ET-013 = escalated) Гард 1 закрывает кейс
|
||||
**без единого сетевого вызова**.
|
||||
|
||||
### Что НЕ меняется (инварианты ORCH-053)
|
||||
|
||||
- Схема БД — **без миграции** (Вариант A). `STAGE_TRANSITIONS` / `QG_CHECKS` —
|
||||
без изменений. Гард — ВНЕ гейта: решает, ЗАПУСКАТЬ ли пред-оценку, а не меняет
|
||||
вердикт.
|
||||
- Never-raise на единицу работы (`reconcile_gate_once` per-task `try/except`
|
||||
сохраняется; новая логика не бросает наружу).
|
||||
- `analysis` carve-out, kill-switch'и (`reconcile_enabled`,
|
||||
`reconcile_plane_enabled`) — как прежде.
|
||||
- F-2 по существу не меняется: Blocked/Needs Input не входят в
|
||||
`{in_progress, approved, rejected}` → не доигрываются (фиксируется
|
||||
регресс-тестом AC-9).
|
||||
|
||||
### Опционально (вне scope AC, рекомендации)
|
||||
|
||||
- Под-флаг `reconcile_skip_blocked_enabled` (default `true`) для независимого
|
||||
отключения только Гарда 2 (сетевого), по аналогии с `reconcile_plane_enabled`.
|
||||
Гард 1 (локальный, безопасный) — всегда активен.
|
||||
- Best-effort счётчик `skipped_escalated` в снимке `GET /queue` (наблюдаемость).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Вариант B — локальный терминальный маркер в БД** (`tasks.blocked` /
|
||||
`tasks.reconcile_skip`, идемпотентный ALTER, выставляется в `set_issue_blocked`
|
||||
/ `set_issue_needs_input` и точках эскалации `gitea.py`). **Отклонён как
|
||||
primary:**
|
||||
- нарушает инвариант ORCH-053 «схема reconciler не меняется» (миграция на живой
|
||||
прод-БД = self-hosting-риск);
|
||||
- затрагивает больше точек записи (4+: две эскалации gitea + два set_issue_*) —
|
||||
выше риск рассинхрона маркера и факта;
|
||||
- для зафиксированного инцидента **не нужен**: Гард 1 (retry-count) закрывает
|
||||
ET-013 детерминированно и без сети.
|
||||
Вариант B остаётся задокументированным будущим упрочнением, если Plane-coupling
|
||||
Гарда 2 окажется болезненным (см. Последствия).
|
||||
- **Подавление в самом `advance_stage` / новый терминальный вердикт гейта** —
|
||||
отклонён: меняет общий критический путь; ORCH-053 уже постановил «не вызывать
|
||||
advance на красном», тот же принцип «не вызывать advance на escalated».
|
||||
- **Гард только по retry (без Гарда 2)** — недостаточно: не покрывает ручной
|
||||
Blocked при retry<лимита; AC-5/AC-6 требуют пропуск.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюсы:** ET-013-петля устранена детерминированно; 0 фантомных разблокировок,
|
||||
0 лишних запусков агентов, 0 спама по escalated-задачам; ручной Blocked/Needs
|
||||
Input уважается; без миграции БД и без изменения реестров → минимальный
|
||||
self-hosting-риск; единый источник истины по retry (промоут хелпера).
|
||||
- **Минусы / плата:**
|
||||
- Гард 2 вводит **per-candidate сетевой вызов** Plane на тике. Митигировано:
|
||||
кандидатов после grace+no-active-job немного; `get_project_states` кэшируется;
|
||||
Гард 1 отсекает escalated до сети.
|
||||
- **Plane-coupling F-1:** при недоступности Plane Гард 2 фоллбэкает в skip →
|
||||
F-1 во время Plane-outage не доигрывает кандидатов с retry<лимита (консерва-
|
||||
тивно «не навреди»). Приемлемо: outage редок/транзиентен; escalated-кейс
|
||||
(Гард 1) от Plane не зависит и продолжает работать; альтернатива
|
||||
(proceed-on-error) рискует вернуть bounce при реальном Blocked. Под-флаг
|
||||
`reconcile_skip_blocked_enabled` даёт ручной обход на время инцидента.
|
||||
- **Self-hosting:** изменение — чистая логика sweeper'а; прод-контейнер не
|
||||
рестартится/не роняется; деплой через staging (8501) по канону.
|
||||
|
||||
## Связи
|
||||
|
||||
- **adr-0007 (reconciler, ORCH-053)** — данный ADR уточняет контракт F-1
|
||||
(`_reconcile_gate_task` приобретает два пред-гарда; инварианты сохранены).
|
||||
- **adr-0003 (условный staging-гейт)** — образец never-raise + флага раската
|
||||
(Гард 2 / `reconcile_skip_blocked_enabled`).
|
||||
- **adr-0001 (реестр проектов)** — `get_project_by_repo` → `plane_project_id`
|
||||
для резолва per-project статусов (Вариант A).
|
||||
- ORCH-046 (retry-счётчик `agent_runs`), ORCH-047 (BLOCKED-вердикт).
|
||||
20
docs/work-items/ORCH-060/10-tech-risks.md
Normal file
20
docs/work-items/ORCH-060/10-tech-risks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Технические риски: ORCH-060
|
||||
|
||||
Work Item ID: ORCH-060
|
||||
Стадия: architecture
|
||||
|
||||
| # | Риск | Вероятность | Влияние | Митигация |
|
||||
|---|------|-------------|---------|-----------|
|
||||
| R-1 | **Plane-coupling F-1.** Гард 2 (Вариант A) делает сетевой вызов на тике; при недоступности Plane все кандидаты с retry<лимита фоллбэкают в skip → F-1 временно не доигрывает. | Низкая (outage редок) | Среднее | Консервативный фоллбэк («не навреди»); escalated-кейс закрыт Гардом 1 без сети; под-флаг `reconcile_skip_blocked_enabled` для ручного обхода; `get_project_states` кэшируется. |
|
||||
| R-2 | **Стоимость поллинга.** Per-candidate GET issue-detail каждые 120с при большом числе stuck-задач. | Низкая | Низкое | Кандидатов после grace+no-active-job мало; Гард 1 (локальный SQL) отсекает escalated до сети; вызов только для переживших Гард 1. |
|
||||
| R-3 | **Промоут хелпера ломает call-sites.** `_developer_retry_count → developer_retry_count`. | Низкая | Среднее | Сохранить приватный алиас `_developer_retry_count = developer_retry_count`; grep всех вызовов перед мержем; покрыто существующими тестами stage_engine. |
|
||||
| R-4 | **Неверный фоллбэк-знак Гарда 2.** Если ошибку трактовать как «не заблокировано» → возврат ET-013-bounce при реальном Blocked. | Средняя (ошибка реализации) | Высокое | ADR явно фиксирует: ошибка/None/нерезолвленный проект → `True` (skip); AC-10 проверяет never-raise+skip. |
|
||||
| R-5 | **Резолв plane-issue-id из task.** В `tasks` два поля (`plane_id` / `plane_issue_id`); неверный выбор → пустой запрос. | Низкая | Низкое | Использовать тот же приоритет, что `get_task_by_plane_id` (оба поля); пустой id → фоллбэк skip. |
|
||||
| R-6 | **Регресс happy-path.** Слишком широкий гард пропустит честно-застрявшие задачи (retry<лимита, не Blocked). | Низкая | Высокое | AC-3/AC-4 (граница ровно на лимите); регресс существующих тестов AC-13. |
|
||||
| R-7 | **Self-hosting деплой.** Изменение работающего в проде sweeper'а. | Низкая | Высокое | Чистая логика, без миграции/рестарт-контрактов; обязательный прогон через staging (8501) перед прод-деплоем; kill-switch `reconcile_enabled`. |
|
||||
|
||||
## Вывод
|
||||
Все риски — низкие/средние по вероятности и митигируемы в рамках выбранной
|
||||
архитектуры (Вариант A, без миграции). Критичен корректный знак never-raise
|
||||
фоллбэка Гарда 2 (R-4) — выделен в AC-10. Схема БД и реестры не меняются →
|
||||
self-hosting-риск минимален.
|
||||
63
docs/work-items/ORCH-060/12-review.md
Normal file
63
docs/work-items/ORCH-060/12-review.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-060
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-060
|
||||
|
||||
## Summary
|
||||
Reviewer-проверка PR `feature/ORCH-060-reconciler-escalated-max-retri` (commit `4db8276`,
|
||||
`fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1`).
|
||||
|
||||
Задача — устранить инцидент ET-013 (бесконечная разблокировка escalated-задачи F-1-реконсайлером).
|
||||
Реализованы два пред-гарда в `Reconciler._reconcile_gate_task` строго ПОСЛЕ существующих гардов
|
||||
(`analysis` carve-out → нет гейта → активный job → grace) и ДО `advance_if_gate_passed`:
|
||||
- **Guard 1** (детерминированный, без сети, проверяется первым): `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`;
|
||||
- **Guard 2** (Вариант A — Plane API, never-raise → консервативный skip): `_is_blocked_or_needs_input(task)`.
|
||||
|
||||
Реализация **полностью соответствует** ТЗ (`02-trz.md`), критериям приёмки (`03-acceptance-criteria.md`)
|
||||
и ADR-001. Все 13 AC покрыты тестами (TC-01…TC-11 + sub-flag + F-2-регресс). `pytest tests/ -q` —
|
||||
**644 passed, 0 регрессий**; `tests/test_reconciler.py` — 27 passed.
|
||||
|
||||
## Соответствие ТЗ / ADR
|
||||
- **Guard 1** — точка вставки, граница `>=`, источник счётчика (`agent_runs`) совпадают с ТЗ §9 и ADR §«Гард 1». ✓
|
||||
- Промоут `stage_engine._developer_retry_count` → публичный `developer_retry_count`, приватный алиас сохранён, все 4 внутренних call-site (`stage_engine.py:565/613/874/950`) работают через алиас — единый источник истины, SQL не дублируется. ✓
|
||||
- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine`, **хардкода `3` в `reconciler.py` нет** (grep подтверждает). ✓ (AC-11)
|
||||
- **Guard 2 — Вариант A** без миграции БД: новый never-raise `plane_sync.fetch_issue_state` (тот же endpoint/headers, что `fetch_issue_sequence_id`), консервативный фоллбэк (`True`→skip) при любой ошибке/`None`/нерезолвленном проекте. Соответствует ADR §«Гард 2» и обоснованию выбора A над B. ✓
|
||||
- Под-флаг `reconcile_skip_blocked_enabled` (default `true`) гасит ТОЛЬКО сетевой Guard 2; Guard 1 всегда активен. ✓
|
||||
- Инварианты ORCH-053 сохранены: схема БД / `STAGE_TRANSITIONS` / `QG_CHECKS` не тронуты; never-raise на единицу работы (`reconcile_gate_once` per-task `try/except` + `_is_blocked_or_needs_input` внутренний `try/except`); тишина при пропуске (ранний `return` до `advance`, без `unblocked_total++`/лога/Telegram); `analysis` carve-out и kill-switch'и не изменены. ✓
|
||||
- API не изменён (`GET /queue` без изменений по содержимому) — соответствует ТЗ §4. ✓
|
||||
|
||||
## Качество кода
|
||||
- Docstrings на новых публичных/значимых функциях (`fetch_issue_state`, `developer_retry_count`, `_is_blocked_or_needs_input`) — содержательные, объясняют контракт never-raise и мотивацию. ✓
|
||||
- Обработка Plane-формата `state` (bare uuid и `{"id": ...}`-вложение) — defensive. ✓
|
||||
- Тесты содержательные (не тривиальные): граница ровно на лимите (TC-04), изоляция исключения с проверкой соседа (TC-10), отсутствие сетевого вызова гейта на escalated (TC-08), регресс F-2 (TC-09). ✓
|
||||
- Self-hosting: чистая логика sweeper'а, прод-контейнер не рестартится/не роняется. ✓
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
> Замечание (P3 / информационно, не блокирует): Guard 2 делает per-candidate сетевой вызов Plane
|
||||
> для ВСЕХ репо (включая не-self-hosting), а не только для `orchestrator`. Это осознанное решение
|
||||
> Варианта A, явно зафиксировано в ADR §«Последствия» (митигировано: кандидатов после grace мало,
|
||||
> `get_project_states` кэшируется, Guard 1 отсекает escalated до сети). Соответствует ADR — не finding.
|
||||
|
||||
## Документация
|
||||
Обновлено в этом же PR (AC-12 — PASS):
|
||||
- `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md` — создан, Accepted, полное обоснование A vs B. ✓
|
||||
- `docs/architecture/README.md` — описание F-1 дополнено skip escalated/Blocked/Needs-Input; footer ORCH-060 переведён в статус «реализовано» с деталями. ✓
|
||||
- `CHANGELOG.md` — запись в `### Fixed` (`fix(reconciler): ...`). ✓
|
||||
- `README.md` — таблица env дополнена `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`. ✓
|
||||
- `.env.example` — канонический ключ + дескриптор добавлены (правило CLAUDE.md №8). ✓
|
||||
|
||||
Документация = golden source: код и доку обновлены синхронно. Нарушений нет.
|
||||
72
docs/work-items/ORCH-060/13-test-report.md
Normal file
72
docs/work-items/ORCH-060/13-test-report.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-060
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-060
|
||||
|
||||
Reconciler F-1 пропускает escalated (retry ≥ MAX_DEVELOPER_RETRIES) и явно
|
||||
Blocked / Needs-Input задачи; happy-path и no-spam сохранены.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8)
|
||||
- Ветка: `feature/ORCH-060-reconciler-escalated-max-retri` @ `55e5e96`
|
||||
(фикс: `4db8276 fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1`)
|
||||
- Дата: 2026-06-07
|
||||
- Review verdict: APPROVED (`12-review.md`)
|
||||
|
||||
## Smoke test API (прод 8500, read-only)
|
||||
> `curl` отсутствует в окружении тестера — проверка выполнена через `python urllib`.
|
||||
> Прод-контейнер НЕ перезапускался / не ронялся (self-hosting, CLAUDE.md §⚠️).
|
||||
|
||||
| Endpoint | HTTP | Ответ |
|
||||
|----------|------|-------|
|
||||
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` |
|
||||
| `GET /status` | 200 | активные задачи отданы (в т.ч. ORCH-060 stage=testing) |
|
||||
| `GET /queue` | 200 | counts/resilience/reconcile-блок отданы |
|
||||
|
||||
## Результаты (test-plan 04-test-plan.yaml → AC)
|
||||
|
||||
| TC ID | AC | Описание | Тест | Результат |
|
||||
|-------|-----|----------|------|-----------|
|
||||
| TC-01 | AC-1 | escalated == MAX_DEVELOPER_RETRIES при зелёном CI → skip | `test_tc060_01_escalated_at_limit_skipped` | PASS |
|
||||
| TC-02 | AC-2 | dev-ранов > MAX → skip | `test_tc060_02_over_limit_skipped` | PASS |
|
||||
| TC-03 | AC-3 | регресс happy-path: retry < MAX → advance dev→review | `test_tc060_03_under_limit_still_advances` | PASS |
|
||||
| TC-04 | AC-4 | граница: ровно MAX skip, MAX−1 advance (ровно одна) | `test_tc060_04_boundary_exactly_one_advances` | PASS |
|
||||
| TC-05 | AC-5 | Plane-статус Blocked → skip | `test_tc060_05_blocked_skipped` | PASS |
|
||||
| TC-06 | AC-6 | Plane-статус Needs Input → skip | `test_tc060_06_needs_input_skipped` | PASS |
|
||||
| TC-07 | AC-7 | no spam на escalated (нет _note_unblock/telegram/qg-fail) | `test_tc060_07_escalated_no_spam` | PASS |
|
||||
| TC-08 | AC-8 | escalated → мок check_ci_green НЕ вызван (skip раньше гейта) | `test_tc060_08_no_gate_call_on_escalated` | PASS |
|
||||
| TC-09 | AC-9 | регресс F-2: Blocked/Needs Input не доигрывается | `test_tc060_09_f2_does_not_replay_blocked` | PASS |
|
||||
| TC-10 | AC-10 | never-raise: ошибка guard2 изолирована, сосед обработан | `test_tc060_10_guard2_never_raise` | PASS |
|
||||
| TC-11 | AC-11 | граница из stage_engine.MAX_DEVELOPER_RETRIES (нет хардкода 3) | `test_tc060_11_limit_from_constant` | PASS |
|
||||
| — | — | под-флаг `reconcile_skip_blocked_enabled` гасит только guard2 | `test_tc060_subflag_disables_only_guard2` | PASS |
|
||||
| TC-12 | AC-13 | регресс: полный прогон test_reconciler.py (ORCH-053 кейсы) | `tests/test_reconciler.py` (27 passed) | PASS |
|
||||
| — | AC-12 | документация (README/ADR/CHANGELOG) — проверено reviewer'ом | — | PASS |
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полный регресс:
|
||||
```
|
||||
$ python -m pytest tests/ -q
|
||||
........................................................................ [ 11%]
|
||||
... (644 dots) ...
|
||||
.................................................................... [100%]
|
||||
644 passed, 1 warning in 15.65s
|
||||
```
|
||||
|
||||
Целевой модуль:
|
||||
```
|
||||
$ python -m pytest tests/test_reconciler.py -v
|
||||
...
|
||||
27 passed, 1 warning in 1.23s
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в `src/config.py:4`, не связано с ORCH-060,
|
||||
существующий технический долг.)
|
||||
|
||||
## Итог
|
||||
**PASS** — все 13 критериев приёмки покрыты и зелёные, полный регресс 644/644,
|
||||
целевой модуль 27/27, smoke API 3/3. Регрессий нет. Задача готова к стадии
|
||||
deploy-staging.
|
||||
80
docs/work-items/ORCH-060/15-staging-log.md
Normal file
80
docs/work-items/ORCH-060/15-staging-log.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
staging_status: FAILED
|
||||
timestamp: 2026-06-07T11:57:34Z
|
||||
base_url: http://localhost:8501
|
||||
mode: stub
|
||||
result: 8/10
|
||||
work_item: ORCH-060
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite **FAILED** (exit code 1, 8/10 checks PASS).
|
||||
|
||||
Canonical run (ORCH-048, ADR-001) — executed INSIDE the `orchestrator-staging`
|
||||
container against the live staging instance:
|
||||
|
||||
```
|
||||
python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
## Failing checks
|
||||
|
||||
- **C9a — Branch appears in `orchestrator-sandbox`** → FAIL (`branch=not found`).
|
||||
After triggering the pipeline via `POST /webhook/plane`, no feature branch was
|
||||
created in the sandbox repo within the 60s poll window.
|
||||
- **C9b — Analyst job enqueued in staging queue** → FAIL. No analyst job appeared
|
||||
in the staging job queue within the 30s window.
|
||||
|
||||
Both failures are in the E2E block (Block C): the webhook was accepted
|
||||
(C8 → HTTP 200 `{'status': 'accepted'}`) and the Plane issue was created (C7 →
|
||||
HTTP 201), but the pipeline did not materialise a branch or enqueue the analyst
|
||||
job — the staging instance did not actually process the triggered task end-to-end.
|
||||
|
||||
## Passing checks (8/10)
|
||||
|
||||
- Block A (SMOKE): A1 /health 200, A2 /queue shape, A3 ORCH_STAGING=true.
|
||||
- Block B (ACCESS): B4 Plane sandbox reachable, B5 Gitea sandbox push=true,
|
||||
B6 registry isolation (sandbox present, prod ET/ORCH absent — confirms the
|
||||
canonical in-container run; B6 would false-FAIL from the host).
|
||||
|
||||
## Verdict
|
||||
|
||||
Machine verdict is authoritative: exit code 1 → `staging_status: FAILED`.
|
||||
Per the conditional staging gate (ORCH-35), a FAILED staging gate for the
|
||||
self-hosting repo rolls the task back to `development`.
|
||||
|
||||
## Raw output
|
||||
|
||||
```
|
||||
============================================================
|
||||
ORCH-33 Staging Check Suite
|
||||
base_url : http://localhost:8501
|
||||
mode : stub
|
||||
utc_time : 2026-06-07T11:55:50.247315+00:00
|
||||
============================================================
|
||||
|
||||
[Block A] SMOKE
|
||||
✓ PASS A1 GET /health → 200 status=ok [HTTP 200, body={'status': 'ok', 'service': 'orchestrator'}]
|
||||
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience [HTTP 200, keys=['counts', 'max_concurrency', 'poll_interval', 'resilience', 'reconcile', 'recent']]
|
||||
✓ PASS A3 ORCH_STAGING=true (not prod) [ORCH_STAGING=true]
|
||||
|
||||
[Block B] ACCESS
|
||||
✓ PASS B4 Plane: sandbox project accessible [HTTP 200, found 5 project(s), sandbox=YES]
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true [HTTP 200, permissions={'admin': True, 'push': True, 'pull': True}]
|
||||
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
|
||||
|
||||
[Block C] E2E (mode=stub)
|
||||
C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=a05995d1-4e3c-44f7-af6f-8bd28fa6367d]
|
||||
C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
|
||||
✗ FAIL C9a Branch appears in orchestrator-sandbox [branch=not found]
|
||||
✗ FAIL C9b Analyst job enqueued in staging queue
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted Plane issue a05995d1-4e3c-44f7-af6f-8bd28fa6367d (HTTP 204)
|
||||
|
||||
============================================================
|
||||
RESULT: 8/10 checks PASS
|
||||
============================================================
|
||||
__EXIT_CODE__=1
|
||||
```
|
||||
@@ -234,12 +234,20 @@ class Settings(BaseSettings):
|
||||
# JSON -> default (mirrors agent_timeout_overrides_json).
|
||||
# reconcile_notify_unblock -> send a Telegram message when a stuck task is
|
||||
# unblocked (F-4 observability).
|
||||
# reconcile_skip_blocked_enabled -> ORCH-060 Guard 2: skip F-1 reconciliation of
|
||||
# issues a human moved to Blocked / Needs Input
|
||||
# (per-candidate Plane state lookup). Disabling it
|
||||
# mutes ONLY the networked Guard 2; Guard 1
|
||||
# (escalated-by-retries, local + deterministic) is
|
||||
# always active. Manual escape hatch during a Plane
|
||||
# outage.
|
||||
reconcile_enabled: bool = True
|
||||
reconcile_interval_s: int = 120
|
||||
reconcile_plane_enabled: bool = True
|
||||
reconcile_grace_default_s: int = 600
|
||||
reconcile_grace_overrides_json: str = ""
|
||||
reconcile_notify_unblock: bool = True
|
||||
reconcile_skip_blocked_enabled: bool = True
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
|
||||
@@ -278,6 +278,33 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
|
||||
"""ORCH-060 (F-1 Guard 2): GET the Plane issue and return its current state uuid.
|
||||
|
||||
Used by the reconciler to honour an explicit human gate: an issue a person
|
||||
moved to **Blocked** / **Needs Input** must not be auto-unblocked by the
|
||||
sweeper. Reuses the exact GET issue-detail endpoint / shared token already
|
||||
used by ``fetch_issue_sequence_id`` / ``fetch_issue_fields``.
|
||||
|
||||
Plane returns ``state`` as a bare uuid string; older shapes may nest it as a
|
||||
``{"id": ...}`` dict — both are handled.
|
||||
|
||||
Returns None on network error, non-2xx, or a missing field — never raises, so
|
||||
the caller can apply its conservative fallback (treat as "possibly blocked").
|
||||
"""
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
state = resp.json().get("state")
|
||||
if isinstance(state, dict):
|
||||
state = state.get("id")
|
||||
return str(state) if state else None
|
||||
except Exception as e:
|
||||
logger.warning(f"fetch_issue_state failed for {issue_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ handlers a webhook would use:
|
||||
canonical quality gate; green -> advance through the unchanged
|
||||
``stage_engine.advance_stage(..., finished_agent=None)``; red -> silence
|
||||
(no advance, no notification). ``analysis`` is NOT reconciled here (human
|
||||
gate; owned by F-2).
|
||||
gate; owned by F-2). **ORCH-060:** before the gate is even evaluated, F-1
|
||||
skips (silently) tasks that are waiting for a human — Guard 1: escalated by
|
||||
developer retries (``developer_retry_count >= MAX_DEVELOPER_RETRIES``,
|
||||
deterministic, local; closes the ET-013 bounce loop) checked first, then
|
||||
Guard 2: an explicit Plane ``Blocked`` / ``Needs Input`` state (Variant A —
|
||||
networked, never-raise -> conservative skip).
|
||||
|
||||
* **F-2 plane-side** (``reconcile_plane_once``): poll the Plane API per
|
||||
project (``list_issues_by_state``) and replay In Progress / Approved /
|
||||
@@ -49,9 +54,13 @@ from .db import (
|
||||
get_task_by_plane_id,
|
||||
has_active_job_for_task,
|
||||
)
|
||||
from .stage_engine import advance_if_gate_passed
|
||||
from .stage_engine import (
|
||||
advance_if_gate_passed,
|
||||
developer_retry_count,
|
||||
MAX_DEVELOPER_RETRIES,
|
||||
)
|
||||
from .stages import get_qg_for_stage
|
||||
from .plane_sync import get_project_states, list_issues_by_state
|
||||
from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state
|
||||
from .webhooks.plane import handle_status_start, handle_verdict
|
||||
from .notifications import send_telegram
|
||||
from . import projects
|
||||
@@ -162,6 +171,17 @@ class Reconciler:
|
||||
age_s = task.get("age_s") or 0
|
||||
if age_s < grace_for_stage(stage):
|
||||
return
|
||||
# ORCH-060 Guard 1: escalated tasks (developer retries reached the cap) are
|
||||
# terminal — they wait for a human, not the sweeper. Without this, a task
|
||||
# whose CI is green but whose reviewer kept sending REQUEST_CHANGES until the
|
||||
# cap would be re-unblocked every tick (incident ET-013, infinite bounce).
|
||||
# Deterministic, local SQL, no network — and checked FIRST (cheapest).
|
||||
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
|
||||
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):
|
||||
return
|
||||
result = advance_if_gate_passed(
|
||||
task_id,
|
||||
stage,
|
||||
@@ -172,6 +192,41 @@ class Reconciler:
|
||||
if result is not None and getattr(result, "advanced", False):
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
|
||||
def _is_blocked_or_needs_input(self, task: dict) -> bool:
|
||||
"""ORCH-060 Guard 2: is this issue in an explicit human Plane gate?
|
||||
|
||||
Variant A (no schema migration): resolve the task's Plane project, fetch
|
||||
the issue's current state uuid and compare against the project's
|
||||
``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so
|
||||
the live Plane state is the source of truth.
|
||||
|
||||
**Never-raise, conservative fallback.** Any error / unresolved project /
|
||||
missing state -> return ``True`` (treat as "possibly blocked" -> skip):
|
||||
NOT unblocking a task is always safe, whereas wrongly unblocking a
|
||||
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.
|
||||
"""
|
||||
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
|
||||
return cur in {states.get("blocked"), states.get("needs_input")}
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(
|
||||
f"reconciler Guard 2: blocked-check failed for task "
|
||||
f"{task.get('id')}, skipping conservatively: {e}"
|
||||
)
|
||||
return True
|
||||
|
||||
# -- F-2: plane-side ---------------------------------------------------
|
||||
def reconcile_plane_once(self) -> None:
|
||||
"""One F-2 pass: poll Plane per project and replay missed transitions."""
|
||||
|
||||
@@ -142,8 +142,14 @@ def _check_review_approved_by_branch(check_fn, repo: str, work_item_id: str, bra
|
||||
return False, f"Error finding PR: {e}"
|
||||
|
||||
|
||||
def _developer_retry_count(task_id: int) -> int:
|
||||
"""How many developer runs have already happened for this task."""
|
||||
def developer_retry_count(task_id: int) -> int:
|
||||
"""How many developer runs have already happened for this task.
|
||||
|
||||
Single source of truth for the developer-retry count: the rollback path
|
||||
(REQUEST_CHANGES / test-fail / merge-gate) and the ORCH-060 reconciler guard
|
||||
both read the cap from here, so the SQL is never duplicated. ``task`` is
|
||||
considered *escalated* once this reaches ``MAX_DEVELOPER_RETRIES``.
|
||||
"""
|
||||
conn = get_db()
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'",
|
||||
@@ -153,6 +159,10 @@ def _developer_retry_count(task_id: int) -> int:
|
||||
return n
|
||||
|
||||
|
||||
# Backward-compat private alias — existing internal call sites keep working.
|
||||
_developer_retry_count = developer_retry_count
|
||||
|
||||
|
||||
def advance_stage(
|
||||
task_id: int,
|
||||
current_stage: str,
|
||||
|
||||
@@ -114,6 +114,47 @@ def _green_ci(monkeypatch, value=(True, "CI green")):
|
||||
return m
|
||||
|
||||
|
||||
# --- ORCH-060 fixtures / helpers -------------------------------------------
|
||||
# State uuids the default "not blocked" fixture maps Blocked / Needs Input to.
|
||||
_BLOCKED_UUID = "blocked-state-uuid"
|
||||
_NEEDS_INPUT_UUID = "needs-input-state-uuid"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def plane_state_not_blocked(monkeypatch):
|
||||
"""ORCH-060 Guard 2 boundary: by default Plane says the issue is NOT in a
|
||||
human gate, so the F-1 happy path runs deterministically offline (no real
|
||||
httpx call). Tests that exercise Guard 2 override ``fetch_issue_state`` to
|
||||
return ``_BLOCKED_UUID`` / ``_NEEDS_INPUT_UUID`` (or raise)."""
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value="some-non-gated-state"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "get_project_states",
|
||||
MagicMock(return_value={
|
||||
"blocked": _BLOCKED_UUID,
|
||||
"needs_input": _NEEDS_INPUT_UUID,
|
||||
}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.projects, "get_project_by_repo",
|
||||
MagicMock(return_value=MagicMock(plane_project_id="proj-test")),
|
||||
)
|
||||
|
||||
|
||||
def _add_dev_runs(task_id, n, agent="developer"):
|
||||
"""Model N developer retries by inserting N agent_runs rows (ORCH-060)."""
|
||||
conn = get_db()
|
||||
for _ in range(n):
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
|
||||
(task_id, agent),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01: happy path — stuck development task is advanced to review
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -377,3 +418,265 @@ def test_tc21_daemon_thread_lifecycle(monkeypatch):
|
||||
|
||||
rec.stop(timeout=5.0)
|
||||
assert not first_thread.is_alive()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ORCH-060: F-1 skips escalated (max developer retries) / Blocked / Needs Input
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 (AC-1): escalated dev task (exactly MAX_DEVELOPER_RETRIES dev runs) at a
|
||||
# green gate is NOT unblocked — stays development, no job, count 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_01_escalated_at_limit_skipped(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development"
|
||||
assert rec.unblocked_total == 0
|
||||
assert _jobs_for(task_id, "reviewer") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 (AC-2): more dev runs than the cap (4–5) -> also skipped (>= boundary).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_02_over_limit_skipped(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES + 2)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development"
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 (AC-3): regression — retry < cap (here 2) still advances to review.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_03_under_limit_still_advances(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES - 1)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "review"
|
||||
assert rec.unblocked_total == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (AC-4): twins — one at the cap (skip), one at cap-1 (advance). Exactly
|
||||
# one advances.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_04_boundary_exactly_one_advances(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
at_limit = _make_task("development", branch="feature/ET-200-a",
|
||||
wi="ET-200", age_s=3600)
|
||||
below = _make_task("development", branch="feature/ET-201-b",
|
||||
wi="ET-201", age_s=3600)
|
||||
_add_dev_runs(at_limit, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
_add_dev_runs(below, stage_engine.MAX_DEVELOPER_RETRIES - 1)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(at_limit) == "development" # skipped
|
||||
assert _stage_of(below) == "review" # advanced
|
||||
assert rec.unblocked_total == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 (AC-5): explicit Plane Blocked (retry < cap) -> skipped.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_05_blocked_skipped(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=_BLOCKED_UUID),
|
||||
)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development"
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 (AC-6): explicit Plane Needs Input (retry < cap) -> skipped.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_06_needs_input_skipped(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=_NEEDS_INPUT_UUID),
|
||||
)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development"
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 (AC-7): no spam — escalated task triggers no unblock log / telegram /
|
||||
# QG-failure notification, across several ticks.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_07_escalated_no_spam(monkeypatch, caplog):
|
||||
_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-210", age_s=3600)
|
||||
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
|
||||
rec = Reconciler()
|
||||
with caplog.at_level("INFO", logger="orchestrator.reconciler"):
|
||||
for _ in range(3):
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert "разблокирована" not in caplog.text
|
||||
tg.assert_not_called()
|
||||
stage_engine.notify_qg_failure.assert_not_called()
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 (AC-8): the gate (check_ci_green) is NOT even evaluated for an escalated
|
||||
# task — Guard 1 skips before the pre-evaluation.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_08_no_gate_call_on_escalated(monkeypatch):
|
||||
ci = _green_ci(monkeypatch)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
_add_dev_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
|
||||
Reconciler().reconcile_gate_once()
|
||||
|
||||
ci.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (AC-9): F-2 never replays Blocked / Needs Input — those states are not
|
||||
# in the polled set, so the handlers are never invoked.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_09_f2_does_not_replay_blocked(monkeypatch):
|
||||
states = {
|
||||
"in_progress": "IP", "approved": "AP", "rejected": "RJ",
|
||||
"blocked": "BL", "needs_input": "NI",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "get_project_states", MagicMock(return_value=states)
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_list(pid, state_uuids):
|
||||
captured["states"] = list(state_uuids)
|
||||
# Plane filters client-side to the requested states, so a Blocked /
|
||||
# Needs Input issue is structurally excluded from the result.
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list)
|
||||
hss = MagicMock()
|
||||
hv = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "handle_status_start", hss)
|
||||
monkeypatch.setattr(reconciler_mod, "handle_verdict", hv)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.projects, "PROJECTS",
|
||||
[MagicMock(repo="enduro-trails", plane_project_id="P")],
|
||||
)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_plane_once()
|
||||
|
||||
assert "BL" not in captured["states"]
|
||||
assert "NI" not in captured["states"]
|
||||
hss.assert_not_called()
|
||||
hv.assert_not_called()
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 (AC-10): never-raise — a Guard 2 lookup that raises for one task is
|
||||
# isolated (that task is conservatively skipped); a neighbour
|
||||
# still advances and the tick does not blow up.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_10_guard2_never_raise(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
bad = _make_task("development", branch="feature/ET-220-bad",
|
||||
wi="ET-220", age_s=3600)
|
||||
ok = _make_task("development", branch="feature/ET-221-ok",
|
||||
wi="ET-221", age_s=3600)
|
||||
|
||||
def flaky(issue_id, project_id):
|
||||
if issue_id == "plane-ET-220":
|
||||
raise RuntimeError("plane boom")
|
||||
return "some-non-gated-state"
|
||||
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state", MagicMock(side_effect=flaky)
|
||||
)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once() # must not raise
|
||||
|
||||
assert _stage_of(bad) == "development" # conservative skip
|
||||
assert _stage_of(ok) == "review" # neighbour advanced
|
||||
assert rec.unblocked_total == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-11): the cutoff comes from MAX_DEVELOPER_RETRIES, not a literal 3.
|
||||
# Patching the constant to 2 makes a 2-run task escalate (it would
|
||||
# have advanced under a hardcoded 3).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_11_limit_from_constant(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod, "MAX_DEVELOPER_RETRIES", 2)
|
||||
task_id = _make_task("development", age_s=3600)
|
||||
_add_dev_runs(task_id, 2) # == patched cap -> skip
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development"
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-10 extra: the sub-flag reconcile_skip_blocked_enabled=False mutes ONLY
|
||||
# Guard 2 (a Blocked task would then be reconciled), while Guard 1
|
||||
# (escalated) stays active.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_subflag_disables_only_guard2(monkeypatch):
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.settings, "reconcile_skip_blocked_enabled", False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=_BLOCKED_UUID),
|
||||
)
|
||||
# Guard 2 disabled -> a Blocked task with retry < cap advances again.
|
||||
blocked = _make_task("development", branch="feature/ET-230-a",
|
||||
wi="ET-230", age_s=3600)
|
||||
# Guard 1 stays active regardless of the sub-flag.
|
||||
escalated = _make_task("development", branch="feature/ET-231-b",
|
||||
wi="ET-231", age_s=3600)
|
||||
_add_dev_runs(escalated, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(blocked) == "review" # Guard 2 muted
|
||||
assert _stage_of(escalated) == "development" # Guard 1 still skips
|
||||
|
||||
Reference in New Issue
Block a user