Compare commits

...

27 Commits

Author SHA1 Message Date
01684a89df fix(docker): drop COPY of gitignored data/ so staging image builds from a worktree
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
The staging-image rebuild (check_staging_image_fresh, ORCH-058) uses the task
git worktree as the docker build context. `data/` is gitignored (runtime SQLite
DB + backups) so it is absent in every worktree -> `COPY data/ ./data/` failed
the build (rc=1) -> deploy-staging rolled back to development (the loop ORCH-061
targets, surfaced one step later once the C9a/C9b waiver let the pipeline reach
the rebuild). The DB always arrives via the compose bind mount, so baking it in
was pointless (and leaked a stale host DB into the image).

Replace `COPY data/ ./data/` with `RUN mkdir -p /app/data` and add a static
regression guard asserting the Dockerfile never COPYs a gitignored path.

Refs: ORCH-061

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 13:39:02 +00:00
e18947d2d9 Merge pull request 'fix(staging): tolerate sandbox-infra-only FAILs (C9a/C9b) in deploy-staging verdict (ORCH-061)' (#62) from feature/ORCH-061-bug-deploy-staging-development into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 16:30:07 +03:00
0ec34d10fc Merge pull request 'docs(ORCH-061): staging gate SUCCESS — C9a/C9b infra-waived' (#63) from docs/ORCH-061-staging-log into main 2026-06-07 16:29:55 +03:00
bf6a0c095a docs(ORCH-061): staging gate SUCCESS — REAL green, C9a/C9b infra-waived
All checks were successful
CI / test (pull_request) Successful in 16s
Validated ORCH-061 infra-tolerance against live staging (8501): all REAL
checks pass, only sandbox-infra C9a/C9b fail and are waived → exit 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 13:29:33 +00:00
39769bdf23 tester(ET): auto-commit from tester run_id=300
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 17s
2026-06-07 13:21:17 +00:00
de47737f4f reviewer(ET): auto-commit from reviewer run_id=299
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 15s
2026-06-07 13:18:47 +00:00
stream
e3f7c1c272 ci: re-trigger after gitea restart (ORCH-061)
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 17s
2026-06-07 13:14:14 +00:00
stream
32a7aa8c6b ci: trigger re-run after host disk cleanup (ORCH-061) 2026-06-07 13:08:38 +00:00
stream
fe8586ed78 ci: re-run after host disk cleanup (ORCH-061) 2026-06-07 13:04:38 +00:00
9070489968 fix(staging): tolerate sandbox-infra-only FAILs (C9a/C9b) in deploy-staging verdict
Some checks failed
CI / test (push) Failing after 39s
CI / test (pull_request) Failing after 35s
The self-hosting orchestrator looped on deploy-staging -> development because
scripts/staging_check.py exited 1 on ANY failed check, so two infra-only checks
(C9a sandbox branch / C9b analyst-job — caused by SANDBOX bot accounts not being
members of the sandbox Plane project, NOT a pipeline regress) forced
staging_status: FAILED -> rollback -> loop, burning developer retries and tokens.

Direction (б) per ADR-001: classify staging checks as REAL (all pipeline checks,
fail-closed) vs SANDBOX_INFRA (narrow allowlist {C9a, C9b}, waivable). New leaf
module src/staging_verdict.py (stdlib-only, never-raise): classify_check +
compute_staging_verdict fold per-check results into a tolerant-but-fail-closed
verdict — any REAL failure -> FAILED/exit1 (safety net holds under any flag);
only C9a/C9b failed & tolerant -> SUCCESS/exit0 with waived list; only infra &
strict -> FAILED/exit1; any internal error -> FAILED/exit1 (never a false green).

staging_check.py now auto-classifies each check (public 3-tuple _items shape kept
as an ORCH-048 b6 regression guard), exposes categorized_items(), prints
INFRA-WAIVED/VERDICT lines, and exits via the verdict; new --strict flag forces
legacy strictness per-run. Kill-switch ORCH_STAGING_INFRA_TOLERANCE_ENABLED
(default true) restores legacy strict mode globally. launcher gains
action_stage_no_changes_note so "no changes to commit" on action stages is logged
as expected, not treated as under-delivery.

Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS registry, staging_status:/
deploy_status: frontmatter, hook exit-code (0/1/2), check_staging_status; no DB
migration. Docs: README, STAGING_CHECK.md, deployer.md, .env.example, CHANGELOG.

Refs: ORCH-061

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:39:00 +00:00
1d1208c136 architect(ET): auto-commit from architect run_id=297
All checks were successful
CI / test (push) Successful in 18s
2026-06-07 12:22:46 +00:00
3ab2690a68 analyst(ET): auto-commit from analyst run_id=296
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 12:10:46 +00:00
3806522041 docs: init ORCH-061 business request
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 15:05:55 +03:00
d4c6cc0f61 Merge pull request 'fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1 (ORCH-060)' (#60) from feature/ORCH-060-reconciler-escalated-max-retri into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 15:01:11 +03:00
210aef6954 deployer(ET): auto-commit from deployer run_id=293
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
2026-06-07 11:59:00 +00:00
1820b0244e Merge pull request 'docs(ORCH-060): staging gate FAILED (8/10) — C9a/C9b E2E' (#61) from docs/ORCH-060-staging-log into main 2026-06-07 14:58:44 +03:00
2f898ede7b docs(ORCH-060): staging gate FAILED (8/10) — C9a/C9b E2E
All checks were successful
CI / test (pull_request) Successful in 17s
Canonical staging_check run inside orchestrator-staging container
(ORCH-048). Exit code 1: branch never appeared in sandbox (C9a) and
analyst job never enqueued (C9b). staging_status: FAILED → rollback
to development per ORCH-35.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:58:29 +00:00
829b914ff7 tester(ET): auto-commit from tester run_id=292
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
2026-06-07 11:54:59 +00:00
55e5e968ae reviewer(ET): auto-commit from reviewer run_id=291
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 22s
2026-06-07 11:53:34 +00:00
4db8276f98 fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
Reconciler F-1 could not tell "stuck by a lost webhook" from "escalated:
max developer retries reached, waiting for a human". With CI green and a
reviewer that kept sending REQUEST_CHANGES up to the cap, every tick
re-unblocked development -> review -> rollback -> re-unblock (incident
ET-013, infinite bounce: wasted agent runs, Telegram spam, parasitic load
on the shared self-hosting instance).

Add two pre-gate guards in Reconciler._reconcile_gate_task (after the
existing analysis/no-gate/active-job/grace guards, before the gate
pre-evaluation), each an early silent return (no advance, no unblocked_total
increment, no notifications):
- Guard 1 (escalated, deterministic, no network, checked first):
  developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES. Promote
  stage_engine._developer_retry_count to public developer_retry_count
  (single source of truth; private alias kept). Limit from the constant,
  not a literal 3.
- Guard 2 (explicit human Plane gate, Variant A, no DB migration): new
  never-raise plane_sync.fetch_issue_state + Reconciler._is_blocked_or_needs_input;
  any error/None/unresolved project -> conservative skip. New sub-flag
  ORCH_RECONCILE_SKIP_BLOCKED_ENABLED mutes only the networked Guard 2.

F-2 unchanged: Blocked/Needs Input are outside {in_progress, approved,
rejected} so they are never replayed (regression test added). DB schema,
STAGE_TRANSITIONS, QG_CHECKS, never-raise, analysis carve-out and
kill-switches untouched.

Refs: ORCH-060

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:50:02 +00:00
efe437a4aa architect(ET): auto-commit from architect run_id=289
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 11:41:02 +00:00
365c67f45d analyst(ET): auto-commit from analyst run_id=288
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 11:28:57 +00:00
d6e0df3550 docs: init ORCH-060 business request
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 14:24:00 +03:00
4d4f542b71 Merge pull request '#59 staging gate FAILED — corrected root cause' into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 14:05:59 +03:00
9e810c89f0 docs(ORCH-058): staging gate FAILED (8/10) — CORRECTED root cause (harness bug, not handler)
All checks were successful
CI / test (pull_request) Successful in 16s
Staging check exit code 1 (C9a/C9b). Live inspection inside orchestrator-staging
proves the production webhook handler is correct: get_project_states(SANDBOX).in_progress
= 84a76f65..., but scripts/staging_check.py hardcodes the enduro fallback b873d9eb...
=> handler correctly classifies the webhook as "no pipeline action". Fix belongs in
scripts/staging_check.py (resolve SANDBOX in_progress dynamically), NOT in handle_status_start
or any ORCH-058 image-freshness code. Image under test = ORCH-058 merge commit 094b5e2f.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:05:37 +00:00
60e5596e94 docs(ORCH-058): staging gate re-run — staging_status FAILED (8/10, C9a/C9b)
E2E pipeline not triggered on staging webhook ("no pipeline action" on
state b873d9eb...); reproduces prior FAILED. Rolls task back to development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 10:42:21 +00:00
bf60f7a48a Merge pull request 'docs(ORCH-058): staging gate re-run on fresh image — staging_status FAILED' (#58) from deployer/ORCH-058-staging-verdict into main 2026-06-07 13:22:14 +03:00
51 changed files with 3386 additions and 49 deletions

View File

@@ -85,6 +85,16 @@ ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
ORCH_IMAGE_FRESHNESS_ENABLED=true
ORCH_IMAGE_FRESHNESS_REPOS=
# ORCH-061: staging-verdict tolerance to sandbox-infra-only FAILs. The self-hosting
# orchestrator looped on deploy-staging because staging_check.py exited 1 on ANY FAIL,
# so two infra-only checks (C9a sandbox branch / C9b analyst-job — caused by SANDBOX
# bot accounts not being members of the sandbox Plane project, NOT a pipeline regress)
# forced staging_status: FAILED -> rollback -> loop. With this ON, C9a/C9b are WAIVED
# to SUCCESS when every REAL check is green; any REAL failure still fails closed.
# true (default) -> tolerant; false -> legacy strict (1:1 pre-ORCH-061, any FAIL rolls back).
# Lives in .env.staging (the staging instance). CLI --strict overrides this per-run.
ORCH_STAGING_INFRA_TOLERANCE_ENABLED=true
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background daemon
# replays a missed stage transition through the SAME gates/handlers a webhook would,
# fixing tasks that got stuck on a dropped event (502 on rebuild, no Plane/Gitea
@@ -95,9 +105,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

View File

@@ -37,8 +37,19 @@ On stage `deploy-staging` your job is to run the staging test suite and write a
not exist. Details: `docs/operations/STAGING_CHECK.md`.
2. Check the exit code:
- Exit code **0** = all tests PASS → `staging_status: SUCCESS`
- Exit code **non-zero** = tests FAILED → `staging_status: FAILED`
- Exit code **0** = advance → `staging_status: SUCCESS`
- Exit code **non-zero** = rollback → `staging_status: FAILED`
> **ORCH-061**: exit 0 may now include *waived* sandbox-infra failures. The two
> infra-only checks **C9a/C9b** (sandbox branch / analyst-job, which depend on
> SANDBOX bot accounts being project members — not on the pipeline) are tolerated
> when every REAL check is green; the script prints an `INFRA-WAIVED:` line and a
> `VERDICT:` line, and still exits 0. Any REAL check failing still yields exit 1
> (fail-closed). If you see `INFRA-WAIVED:` in the output, copy that line into the
> `15-staging-log.md` body for observability. The exit-code → `staging_status`
> mapping above is unchanged: trust the exit code, do NOT re-judge waived checks.
> Kill-switch: `ORCH_STAGING_INFRA_TOLERANCE_ENABLED=false` (or `--strict`) restores
> legacy strictness. Details: `docs/operations/STAGING_CHECK.md`.
3. Write the verdict to `docs/work-items/<work_item_id>/15-staging-log.md` with YAML frontmatter:
```markdown

4
.task-arch.md Normal file
View File

@@ -0,0 +1,4 @@
Work item: ORCH-061
Repo: orchestrator
Branch: feature/ORCH-061-bug-deploy-staging-development
Stage: architecture

4
.task-dev.md Normal file
View File

@@ -0,0 +1,4 @@
Work item: ORCH-061
Repo: orchestrator
Branch: feature/ORCH-061-bug-deploy-staging-development
Stage: development

8
.task.md Normal file
View File

@@ -0,0 +1,8 @@
Work item: ORCH-061
Repo: orchestrator
Branch: feature/ORCH-061-bug-deploy-staging-development
Stage: analysis
Title: BUG: deploy-staging петля — откат на development (self-deploy)
Description:
Симптом: на стадии deploy-staging для self-hosting orchestrator задача откатывается deploy-staging -> development и крутится по кругу.ДВЕ подтверждённые причины (ORCH-58 + ORCH-60):1. check_staging_status FAILED (ложный). deployer гоняет staging_check.py, тот падает на C9a/C9b (sandbox e2e: branch not found + analyst job in queue) с пометкой «Plane comment check skipped: bot-tokens not added to SANDBOX project». 8/10 PASS, 2 ложных FAIL из-за ненастроенных bot-токенов SANDBOX-проекта. QG check_staging_status -> FAILED -> rollback deploy-staging->development. Это НЕ регресс кода, а отсутствие sandbox-настроек.2. no changes to commit. для action-стадий (деплой = рестарт/retag, не правка кода) deployer exit0 + «no changes» тоже трактуется stage_engine как недовыполнение -> откат.Последствие: прод-деплой self-hosting репо НЕВОЗМОЖЕН автономно — ORCH-58 и ORCH-60 доводились ВРУЧНУЮ (merge PR + build-once retag + --deploy). Прямой блокер автономного внедрения (эпик ORCH-54).Fix-направления (одно или оба):(а) Настроить sandbox bot-токены в SANDBOX Plane-проект, чтобы staging_check C9a/C9b проходили честно (10/10). Тогда check_staging_status не будет ложно падать.(б) Отвязать advance deploy-стадии от git-changes для self-deploy репо: успех = exit0 + health PASS (+ опц. staging_check), а не наличие коммита.Acceptance: ORCH-задача для self-hosting orchestrator проходит deploy-staging -> deploy -> Done БЕЗ ручного вмешательства и без петли. Priority P0.

View File

@@ -29,6 +29,9 @@
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
- **Staging-образ снова собирается из git-воркти (петля `deploy-staging → development` на `docker build` rc=1)** (ORCH-061): после устранения ложного infra-FAIL (C9a/C9b) конвейер впервые дошёл до пересборки staging-образа (`check_staging_image_fresh`, ORCH-058) и упал на следующем шаге той же петли: `Dockerfile` содержал `COPY data/ ./data/`, но `data/` **в `.gitignore`** (рантайм-БД SQLite + бэкапы) → отсутствует в КАЖДОМ git-воркти. Staging-rebuild (`hook --build-staging`) использует **воркти задачи** как build-context, где `data/` нет → `docker build` падает с rc=1 (`BUILD-STAGING: docker build failed`) → откат `deploy-staging → development` → петля. Прод-сборка из основного чекаута (`/repos/orchestrator`, где `data/` существует как рантайм-каталог) маскировала дефект и заодно бесполезно «запекала» хостовую БД (~100 МБ бэкапов, утечка устаревшего состояния) в образ — рантайм всё равно перекрывает её bind-mount'ом compose (`./data:/app/data` прод, `./data/staging:/app/data` staging). Фикс: `COPY data/ ./data/` заменён на `RUN mkdir -p /app/data` — цель монтирования существует в образе, сборка не зависит от gitignore-каталога, SQLite создаёт `.db` сам. Контракты не тронуты: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_staging_image_fresh`/`check_staging_status`, OCI-лейбл `org.opencontainers.image.revision` (ORCH-058), exit-code хука; схема БД и compose-тома — без изменений. Регрессия-гард (статически, без docker): `tests/test_dockerfile_worktree_buildable.py``Dockerfile` не должен `COPY` ни одного gitignore-каталога (иначе сборка из воркти снова сломается).
- **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development``scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`.
- **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`.

View File

@@ -20,6 +20,15 @@ RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY data/ ./data/
# ORCH-061: do NOT `COPY data/ ./data/`. `data/` is gitignored (runtime SQLite DB
# + backups), so it is ABSENT in every git worktree. The staging-image rebuild of
# ORCH-058 (`check_staging_image_fresh` / hook `--build-staging`) uses the task
# WORKTREE as the build context, where `data/` does not exist -> `COPY data/`
# fails the build (rc=1) -> deploy-staging rolls back to development (the loop this
# task fixes). It is also pointless: the DB always arrives via the compose bind
# mount (`./data:/app/data` prod, `./data/staging:/app/data` staging), which
# overrides anything baked in (and baking the host DB into the image leaks stale
# state). Just ensure the mount target exists; sqlite creates the .db file.
RUN mkdir -p /app/data
ENV PYTHONPATH=/app
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]

View File

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

View File

@@ -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.
@@ -42,6 +42,16 @@ created → analysis → architecture → development → review → testing →
### Условный staging-гейт (ORCH-35)
`check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)``orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md).
### Толерантность staging-вердикта к инфра-FAIL (ORCH-061 — design)
Self-hosting зацикливался на `deploy-staging`: `scripts/staging_check.py` давал ложный FAILED на C9a/C9b (ветка в sandbox / analyst-job в очереди), вызванный **отсутствием sandbox-настроек** (bot-аккаунты не члены SANDBOX-проекта), а не регрессом кода → откат `deploy-staging → development` → петля. ORCH-061 классифицирует проверки suite на **REAL** (pipeline) и **SANDBOX_INFRA** (узкий allowlist `{C9a, C9b}`) и делает вердикт толерантным к инфра-FAIL, сохраняя fail-closed для реальных проверок:
- Чистая логика — leaf-модуль `src/staging_verdict.py` (`classify_check`, `compute_staging_verdict`, never-raise). Упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и толерантность вкл → SUCCESS/exit0 (waived); waiver применяется только когда все REAL (вкл. C7/C8) зелёные.
- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через `staging_verdict`, печатает `INFRA-WAIVED` (наблюдаемость).
- Kill-switch `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`, в `.env.staging`); `false` → 1:1 прежнее строгое поведение.
- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр `QG_CHECKS`**без изменений** (новый QG-чек не вводится); условность ORCH-35 и схема БД сохранены.
- Инвариант: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`) не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом (launcher не откатывает; добавлена observability-строка).
Подробнее: [adr-0009](adr/adr-0009-staging-infra-tolerance.md), детально — `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`.
### Merge-gate: догон `main` + re-test + сериализация слияний (ORCH-043)
Детерминированный под-гейт (`check_branch_mergeable`, без LLM) на ребре **`deploy-staging → deploy`**: исполняется ПОСЛЕ `check_staging_status` и ДО запуска deployer'а, который вливает PR в `main` (deployer мержит в начале стадии `deploy`). Стадии (`STAGE_TRANSITIONS`) НЕ меняются — это «под-гейт» ребра, а не отдельная стадия (триггер — то же событие «staging-deployer завершился»).
@@ -108,6 +118,14 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
**Инвариант build-context (ORCH-061):** staging-rebuild собирает образ из **git-воркти**
задачи, а воркти содержит только git-tracked файлы. Поэтому `Dockerfile` НЕ должен
`COPY` ни одного gitignore-пути — иначе `docker build` падает (rc=1) и `deploy-staging`
зацикливается на откате в `development`. В частности `data/` (рантайм-БД + бэкапы)
gitignore'нут и приходит исключительно через compose bind-mount (`./data:/app/data`),
поэтому образ лишь создаёт каталог монтирования (`RUN mkdir -p /app/data`), а не копирует
его. Гард — `tests/test_dockerfile_worktree_buildable.py`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
@@ -118,6 +136,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 +219,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); 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).*

View File

@@ -12,6 +12,14 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0005 | Контейнеры бегут под uid:gid хоста (1000:1000) | accepted | 2026-06-06 | ORCH-040 |
| adr-0006 | Merge-gate (догон main + re-test + сериализация слияний) | proposed | 2026-06-06 | ORCH-043 |
| adr-0007 | Reconciler застрявших стадий (sweeper потерянных webhook) | accepted | 2026-06-06 | ORCH-053 |
| adr-0007 | Исполняемый самодеплой стадии `deploy` (файл adr-0007-executable-self-deploy) | accepted | 2026-06-06 | ORCH-036 |
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
> свободный номер (текущий максимум — `0009`).
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

View File

@@ -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 как под-гейт ребра

View File

@@ -0,0 +1,56 @@
# adr-0009: Толерантность staging-вердикта к заведомо инфраструктурным FAIL
- **Статус:** accepted
- **Дата:** 2026-06-07
- **Задача:** ORCH-061
- **Детально:** `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`
## Контекст
Self-hosting `orchestrator` зацикливался на `deploy-staging`: `staging_check.py`
давал 2 ложных FAIL (C9a — ветка в sandbox, C9b — analyst-job в очереди), вызванных
отсутствием sandbox-настроек (bot-аккаунты не члены SANDBOX-проекта), а не регрессом
кода. `staging_check.py` делал `sys.exit(1)` при любом FAIL → deployer писал
`staging_status: FAILED``check_staging_status` FAILED → откат `deploy-staging →
development` → петля (жгла developer-ретраи и кредиты). Прод-деплой орка приходилось
доводить вручную — блокер автономного внедрения (ORCH-54).
## Решение
Классифицировать проверки staging-suite на **REAL** (pipeline) и **SANDBOX_INFRA**
(заведомо инфраструктурные, узкий allowlist `{C9a, C9b}`) и сделать вердикт
толерантным к инфра-FAIL, сохранив fail-closed для реальных проверок:
- Новый leaf-модуль `src/staging_verdict.py` (pure, never-raise, stdlib):
`classify_check(label)` + `compute_staging_verdict(items, infra_tolerant)`.
Правило: упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и
толерантность вкл → SUCCESS/exit0 (waived); толерантность выкл → legacy strict
(любой FAIL → FAILED).
- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через
`staging_verdict`, печатает `INFRA-WAIVED` при вайвере (наблюдаемость).
- Kill-switch `staging_infra_tolerance_enabled` (env
`ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `True`; в `.env.staging`).
- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр
`QG_CHECKS`**без изменений**; новый QG-чек не вводится. Условность ORCH-35
сохранена (не-self → no-op N/A).
- Инвариант FR-3: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`)
не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом
(launcher уже не откатывает; добавлена observability-строка).
## Альтернативы
- Только починить sandbox-инфру (направление а) — хрупко, не структурно, вне
автономной досягаемости таска; оставлено как опциональное hardening.
- «Зелёный по умолчанию» при недоступности проверок — запрещён (fail-closed).
- Новый QG-чек / структурный артефакт `15-staging-log.md` — избыточно, меняло бы
контракты/реестр; толерантность размещена в suite до артефакта.
## Последствия
- Петля устранена; страховка цела (реальный регресс → FAILED → откат).
- Чистая вердикт-логика юнит-тестируема без live staging/docker.
- Контракты гейтов/стадий/вердиктов/реестра и схема БД неизменны.
- Риск: узкое окно — реальный регресс именно в создании ветки/постановке
analyst-job может быть заваивен; митигировано allowlist'ом `{C9a,C9b}` + условием
«все REAL (вкл. C7/C8) зелёные» + INFRA-WAIVED-логом. Разблокирует ORCH-54.
## Связи
adr-0003 (условный staging-гейт — база `is_self_hosting_repo` / `check_staging_status`),
adr-0006 (merge-gate), adr-0007 (исполняемый self-deploy), adr-0008 (провенанс
staging-образа). Блокирует ORCH-54.

View File

@@ -12,7 +12,9 @@
| B | ACCESS | Plane sandbox (R), Gitea sandbox (R+push), реестр проектов |
| C | E2E | Создать задачу → триггер конвейера → ветка + коммент → cleanup |
Exit code: **0** = все PASS, **non-zero** = есть FAIL.
Exit code: **0** = advance (все REAL-проверки PASS), **1** = rollback (есть REAL-FAIL).
С ORCH-061 exit 0 может включать *waived* sandbox-infra FAIL (C9a/C9b) — см.
[«Толерантность к sandbox-infra (ORCH-061)»](#толерантность-к-sandbox-infra-orch-061).
---
@@ -85,6 +87,56 @@ B6 «Registry: sandbox present, prod ET/ORCH absent» подтверждает
---
## Толерантность к sandbox-infra (ORCH-061)
**Проблема.** Self-hosting `orchestrator` зацикливался на `deploy-staging → development`:
прежде скрипт давал exit 1 при **любом** FAIL, поэтому две чисто инфраструктурные
проверки — **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job
аналитика не встал в очередь staging) — приводили к `staging_status: FAILED`
откат → цикл. Корень: SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane,
поэтому шаги 6+ конвейера в песочнице недостижимы. Это **не** регресс конвейера.
**Решение.** Проверки классифицируются на две категории (`src/staging_verdict.py`):
| Категория | Что входит | Поведение |
|-----------|-----------|-----------|
| `REAL` | все проверки конвейера (A*, B*, C7, C8) | **fail-closed** — любой FAIL = rollback |
| `SANDBOX_INFRA` | строго allowlist `{C9a, C9b}` | **waivable** — FAIL терпится, если все REAL зелёные |
Вердикт сворачивается в `compute_staging_verdict(items, infra_tolerant)`:
- любой REAL-FAIL → `FAILED` / exit 1 (страховка сохраняется при ЛЮБОМ значении флага);
- упали **только** C9a/C9b и толерантность включена → `SUCCESS` / exit 0,
упавшие метки попадают в `waived` (наблюдаемость, печатается строкой `INFRA-WAIVED:`);
- упали только C9a/C9b, толерантность выключена → `FAILED` / exit 1 (legacy-строгий);
- любая внутренняя ошибка вердикта → `FAILED` / exit 1 (никогда не ложный green).
Blast-radius waiver-а ровно две allowlist-метки; всё неизвестное классифицируется
как `REAL` (fail-closed).
### Kill-switch и `--strict`
| Управление | Эффект |
|-----------|--------|
| env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (default `true`) | глобальный флаг; `false` → строгий режим (1:1 до ORCH-061) |
| CLI `--strict` | форсит строгий режим для одного запуска, игнорируя env |
Флаг живёт в `.env.staging` (staging-инстанс). `--strict` имеет приоритет над env.
### Что печатает скрипт
В конце прогона `summary()` показывает разбивку REAL/SANDBOX_INFRA, затем:
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox; C9b Analyst job enqueued ...
VERDICT: SUCCESS (infra-waived): ['C9a …', 'C9b …'] are known sandbox-infra checks; all real checks green
```
Контракт `staging_status: SUCCESS|FAILED` во frontmatter **не меняется**
толерантность применяется в скрипте ДО записи артефакта деплоером.
---
## Режимы (`--mode`)
| Режим | Описание | Скорость |

View File

@@ -1,17 +1,24 @@
---
staging_status: FAILED
timestamp: 2026-06-07T10:14:30Z
timestamp: 2026-06-07T11:01:00Z
base_url: http://localhost:8501
---
# Staging Gate Log — ORCH-058
Staging test suite ran against the live staging environment and **FAILED** (exit code `1`,
**8/10 checks PASS**). The two end-to-end (Block C) checks failed: the pipeline was **not
triggered** on the freshly-built staging image, so no task / branch / analyst job was created.
**8/10 checks PASS**). Block C (E2E) checks C9a and C9b failed.
Per the staging-gate contract this is a machine verdict `FAILED` → the task rolls back to
`development`. The verdict reflects the real suite exit code, not an LLM declaration.
Per the staging-gate contract this is the machine verdict `FAILED` (it reflects the real suite
exit code, never an LLM declaration). Smoke (A1A3) and access (B4B6) all passed, **including
B6 registry isolation** — so this is NOT a B6/ORCH-048 false-FAIL.
> ⚠️ **CORRECTED ROOT CAUSE — read before acting on this rollback.** The previous revision of
> this log blamed `handle_status_start` / a regression in the validated artifact. **That was
> wrong**, which is why the dev↔staging cycle kept repeating. Direct inspection inside the
> running staging instance proves the production code is **correct** and the failure is a bug in
> the **test harness `scripts/staging_check.py`**. Do NOT touch `src/webhooks/plane.py` /
> `handle_status_start` / any ORCH-058 image-freshness code. **Fix `scripts/staging_check.py`.**
## Execution
- Canonical `docker exec` into `orchestrator-staging` (ORCH-048, ADR-001), invoked via the
@@ -19,43 +26,62 @@ Per the staging-gate contract this is a machine verdict `FAILED` → the task ro
agent runtime image; the Engine-API exec is the exact equivalent of
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`).
- Script: `/repos/orchestrator/scripts/staging_check.py` (bind-mount, `main`).
- Script: `/repos/orchestrator/scripts/staging_check.py` (bind-mount, served from the host repo,
NOT baked into the image — so a harness fix takes effect on the next run without a rebuild).
- Mode: `stub`
- Exit code: `1`
- Result: **8/10 checks PASS** (FAIL: C9a, C9b)
- Staging image under test: `orchestrator-orchestrator-staging`, OCI label
`org.opencontainers.image.revision=094b5e2f960f696216f8661ff9c27b0d4706f219` (= the **merge
commit of ORCH-058 into `main`**, PR #57; ancestor of branch HEAD `60e5596e`). Container
recreated 2026-06-07T10:13:36Z. So the artifact under test genuinely contains the validated
ORCH-058 code.
## Root cause (actionable for development rollback)
The E2E flow (`staging_check.py` Block C) creates a SANDBOX Plane issue (C7 ✓), then POSTs a
signed `/webhook/plane` payload with state `IN_PROGRESS_STATE_ID` (name `"In Progress"`,
group `"started"`) to start the pipeline (C8 ✓ — HTTP 200 `{"status":"accepted"}`). However the
staging instance logged:
## Decisive root cause (proven, actionable)
Block C creates a SANDBOX Plane issue (C7 ✓), then POSTs a signed `/webhook/plane` payload to
start the pipeline (C8 ✓ — HTTP 200 `{"status":"accepted"}`). The staging instance logged for
the test issue `427cb94e-…`:
```
2026-06-07 10:14:09 [INFO] orchestrator.webhooks.plane: issue ed5db89e-657d-4728-9179-901d2404be85
2026-06-07 10:59:04 [INFO] orchestrator.webhooks.plane: issue 427cb94e-cedd-4def-ba5d-21c555a82477
updated to state b873d9eb..., no pipeline action
```
**"no pipeline action"**: the `In Progress` / `started` webhook did NOT start the pipeline,
so no `tasks` row, no Gitea branch (C9a FAIL — branch never appeared after 60s), and no analyst
job enqueued (C9b FAIL — queue had no new job; latest job is id=8 from 2026-06-06). Cleanup
confirmed `no task row found for plane_id=ed5db89e...` and `no branch to delete`.
`handle_issue_updated` (src/webhooks/plane.py) starts the pipeline **only** when the webhook's
new state equals the **incoming project's** `in_progress` state, resolved per-project from the
Plane API by `get_project_states(project_id)` (ORCH-10). The webhook the harness sends carries
state `b873d9eb-993c-48cd-97ac-99a9b1623967`.
This is a **deterministic regression in the validated artifact**, not a timing flake (the
webhook was explicitly classified as no-op, not a poll timeout):
- The **same** `staging_check.py` against the **same** SANDBOX config passed **10/10** at
09:31 UTC on the pre-rebuild image (see git history of this file).
- The staging image was **freshly rebuilt** at 10:13:29 UTC (revision label
`org.opencontainers.image.revision=094b5e2f960f696216f8661ff9c27b0d4706f219`, container
recreated 10:13:36 UTC) — consistent with ORCH-058 Strategy A rebuilding 8501 from the
validated commit. The new image now exposes the `reconcile` key in `/queue` (ORCH-053),
absent at 09:31, confirming the image changed between the two runs.
- Net: the artifact about to be promoted to prod no longer starts the pipeline on a Plane
`In Progress` (group `started`) transition. **Investigate `handle_status_start` /
webhook start-state matching in `src/webhooks/plane.py`** against the validated commit.
**The mismatch (queried live inside the staging container):**
Smoke (A1A3) and access (B4B6) all passed, including B6 registry isolation
(sandbox present; prod ET/ORCH absent) — confirming the check ran inside the staging
instance's own process-env, so there is no false-FAIL / spurious-rollback risk from B6.
| | UUID |
|---|---|
| `staging_check.py` `IN_PROGRESS_STATE_ID` (hardcoded) | `b873d9eb-993c-48cd-97ac-99a9b1623967` |
| `get_project_states(SANDBOX)["in_progress"]` (real) | `84a76f65-75f8-4022-9554-379dad38523c` |
| `_DEFAULT_STATES["in_progress"]` (enduro-trails fallback) | `b873d9eb-993c-48cd-97ac-99a9b1623967` |
The hardcoded `b873d9eb…` is the **enduro-trails** In Progress UUID (the `_DEFAULT_STATES`
fallback), **not** SANDBOX's. SANDBOX's actual In Progress is `84a76f65…`. So the handler
**correctly** classifies the enduro-state webhook as `no pipeline action` for a SANDBOX issue →
no `tasks` row, no Gitea branch (C9a FAIL after 60s), no analyst job enqueued (C9b FAIL).
Cleanup confirmed `no task row found` and `no branch to delete`.
**Why it intermittently "passed 10/10" before (09:31):** `get_project_states` falls back to
`_DEFAULT_STATES` (= `b873d9eb…`) whenever the Plane states API call fails / returns no
recognisable states. On runs where that fallback fired, the hardcoded harness state accidentally
matched and the pipeline started. On this run the SANDBOX states API call succeeded at startup
(`GET …/projects/8c5a3025-…/states/ → 200 OK`), so SANDBOX resolved to its real `84a76f65…` and
the accidental match disappeared. The green runs were the bug; the red runs are correct handler
behaviour exposing a harness that hardcodes the wrong project's state.
## Required fix (for the development rollback) — in `scripts/staging_check.py` ONLY
Make the E2E harness send SANDBOX's **actual** `in_progress` state instead of a hardcoded enduro
UUID. Resolve it dynamically the same way the app does — e.g. `GET
/workspaces/<slug>/projects/<SANDBOX_PROJECT_ID>/states/`, pick the state whose `name` is
`"In Progress"` (group `"started"`), and use its `id` in `_make_webhook_payload`. (The harness
already calls the Plane API for B4/B6, so credentials/URL are available.) Do **not** rely on the
`_DEFAULT_STATES` fallback coincidence. No production-code change is warranted; ORCH-058's
image-provenance feature is unaffected by this and is functioning.
## Test output
@@ -64,7 +90,7 @@ instance's own process-env, so there is no false-FAIL / spurious-rollback risk f
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-07T10:14:07.188198+00:00
utc_time : 2026-06-07T10:59:02.392888+00:00
============================================================
[Block A] SMOKE
@@ -79,7 +105,7 @@ instance's own process-env, so there is no false-FAIL / spurious-rollback risk f
[Block C] E2E (mode=stub)
· C7: Creating issue in SANDBOX project...
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=ed5db89e-657d-4728-9179-901d2404be85]
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=427cb94e-cedd-4def-ba5d-21c555a82477]
· C8: Triggering pipeline via POST /webhook/plane ...
· Using HMAC signature (secret len=40)
✓ PASS C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
@@ -93,8 +119,8 @@ instance's own process-env, so there is no false-FAIL / spurious-rollback risk f
[CLEANUP]
· CLEANUP: no branch to delete
✓ PASS CLEANUP: deleted Plane issue ed5db89e-657d-4728-9179-901d2404be85 (HTTP 204)
· CLEANUP DB: no task row found for plane_id=ed5db89e-657d-4728-9179-901d2404be85
✓ PASS CLEANUP: deleted Plane issue 427cb94e-cedd-4def-ba5d-21c555a82477 (HTTP 204)
· CLEANUP DB: no task row found for plane_id=427cb94e-cedd-4def-ba5d-21c555a82477
· CLEANUP DB dedup: no such table: events_dedup
============================================================

View File

@@ -0,0 +1,7 @@
# Business Request: Reconciler не должен трогать escalated / max-retries задачи
Work Item ID: ORCH-060
## Description
TBD

View 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` → снова эскалация (стадия
не меняется). Следующий тик — снова разблокировка. Бесконечный цикл.
**Реальный инцидент (наблюдение 0607.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 по-прежнему доигрывает.

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

View 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` (например 45).
- **Когда:** `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:** любой ранее зелёный тест упал.

View 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 (45) → также 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

View File

@@ -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, 0607.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-вердикт).

View 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-риск минимален.

View 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: код и доку обновлены синхронно. Нарушений нет.

View 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, MAX1 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.

View 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
```

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: deploy-staging петля — откат на development (self-deploy)
Work Item ID: ORCH-061
## Description
TBD

View File

@@ -0,0 +1,117 @@
# 01 — BRD: BUG — deploy-staging петля (откат deploy-staging → development) для self-deploy
Work Item: **ORCH-061**
Тип: **BUG**
Приоритет: **P0**
Репозиторий: `orchestrator` (self-hosting)
Эпик-контекст: блокер **ORCH-54** (автономное внедрение self-hosting)
---
## 1. Резюме (Executive summary)
На стадии `deploy-staging` для self-hosting репозитория `orchestrator` задача
зацикливается: гейт ребра `deploy-staging → deploy` даёт FAILED, `stage_engine`
откатывает задачу `deploy-staging → development`, developer-агент перезапускается,
проходит конвейер заново, снова упирается в `deploy-staging`, снова откат — и так
по кругу (с расходом developer-ретраев и кредитов LLM), либо до исчерпания лимита
ретраев и блокировки.
Следствие: **прод-деплой self-hosting репо невозможен автономно**. Последние
ORCH-задачи (ORCH-58, ORCH-60) доводились до прода **вручную** (ручной merge PR +
ручной build-once retag + ручной `--deploy`). Это прямой блокер автономного
внедрения (эпик ORCH-54).
## 2. Бизнес-контекст и проблема
Оркестратор дорабатывает сам себя (self-hosting). Стадия `deploy-staging`
(порт 8501) — обязательная страховка перед прод-деплоем орка (ORCH-35, ADR-0003).
На этой стадии deployer гоняет `scripts/staging_check.py` против живого
staging-стенда и пишет машинный вердикт `staging_status: SUCCESS|FAILED` в
`15-staging-log.md`. Гейт `check_staging_status` читает этот вердикт; `FAILED`
→ откат на `development` (ORCH-35, `_handle_qg_failure_rollbacks`).
Подтверждены **две независимые причины** зацикливания.
### Причина №1 — ложный FAILED `check_staging_status` (контекст ORCH-58)
`staging_check.py` в sandbox-прогоне даёт **8/10 PASS, 2 ложных FAIL** на e2e-блоке
Block C:
- **C9a** — ветка не появляется в `orchestrator-sandbox` (branch not found);
- **C9b** — analyst-job не появляется в staging-очереди (`/queue → recent`).
Сопутствующая пометка suite: «Plane comment check skipped: bot-tokens not added to
SANDBOX project» — bot-аккаунты агентов (`ORCH_PLANE_BOT_*`) не добавлены членами
SANDBOX-проекта Plane (проект создан после провижининга ботов). Это **отсутствие
sandbox-настроек инфраструктуры, а не регресс кода**. Тем не менее `staging_check.py`
возвращает ненулевой exit-code → deployer пишет `staging_status: FAILED` → гейт
FAILED → откат `deploy-staging → development`.
### Причина №2 — «no changes to commit» на action-стадии (контекст ORCH-60)
Стадии деплоя по своей природе **действие, а не правка кода** (рестарт/retag), и
закономерно не порождают git-изменений в `src/`/`tests/`. Сигнал «no changes»
для action-стадии не должен трактоваться как недовыполнение работы; критерий успеха
action-стадии — успешное выполнение действия (exit0 + доказанный health/staging),
а не наличие нового коммита. Сейчас отсутствие изменений на стадии деплоя приводит
к недопродвижению задачи и откату.
### Совокупный эффект
Любая из причин по отдельности достаточна, чтобы зациклить self-deploy. Обе
проявились на реальных задачах ORCH-58 и ORCH-60, которые пришлось доводить вручную.
## 3. Цели (Goals)
- **G1.** ORCH-задача для self-hosting `orchestrator` проходит
`deploy-staging → deploy → done` **без ручного вмешательства** и **без петли**.
- **G2.** Ложный (инфраструктурный) FAIL `staging_check` в sandbox **не вызывает**
откат `deploy-staging → development`.
- **G3.** Отсутствие git-изменений на стадиях деплоя (`deploy-staging` / `deploy`)
**не трактуется** как недовыполнение и не приводит к откату.
- **G4.** Реальный регресс (настоящий провал staging-проверки или прод-деплоя)
**по-прежнему** приводит к откату `→ development` (страховка не ослабляется).
## 4. Вне области (Non-goals)
- Полная автоматизация ручного approve прод-деплоя (это ORCH-54).
- Изменение конвейера стадий (`STAGE_TRANSITIONS`), реестра гейтов как структуры,
контрактов `check_deploy_status` / `check_staging_status` frontmatter-вердиктов.
- Изменение поведения для **не**-self-hosting репозиториев (enduro-trails и пр.):
для них staging-гейт и self-deploy остаются no-op / прежними.
- Изменение схемы БД.
## 5. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Owner / оператор оркестратора | Автономный self-deploy без ручных шагов и без ночных петель. |
| Другие проекты (enduro-trails) | Их конвейер не должен быть затронут (общий инстанс, общая очередь). |
| Агенты (deployer) | Чёткий, не ложно-срабатывающий контракт стадии деплоя. |
## 6. Кандидатные направления решения (из бизнес-запроса)
Бизнес-запрос называет два направления (одно или оба); **выбор и механизм —
за архитектором (ADR)**, BRD требует лишь достижения G1G4:
- **(а)** Сделать sandbox-прогон `staging_check` честным (например, настроить
bot-токены SANDBOX Plane-проекта / починить sandbox e2e), чтобы C9a/C9b
проходили честно (10/10) и `check_staging_status` не падал ложно.
- **(б)** Отвязать продвижение стадий деплоя от git-changes для self-deploy:
успех action-стадии = exit0 + health/staging PASS, а не наличие коммита.
## 7. Бизнес-эффект / риски бездействия
- **Эффект:** разблокировка автономного внедрения self-hosting (ORCH-54);
устранение ручного труда (merge + retag + deploy) и риска ошибки при ручных шагах.
- **Риск бездействия:** каждая ORCH-задача требует ручного дотягивания до прода;
петли жгут кредиты LLM и developer-ретраи, задачи блокируются.
## 8. Допущения
- Прод-контейнер `orchestrator` (8500) обслуживает все проекты из общего инстанса —
его **нельзя** ронять/перезапускать в рамках задачи (см. CLAUDE.md, INFRA.md).
- Изменения касаются self-hosting пути (`is_self_hosting_repo` / `self_deploy_applies`);
для прочих репо поведение не меняется.
- Документация — golden source: затронутые `docs/architecture/README.md`,
`docs/operations/STAGING_CHECK.md`, `CHANGELOG.md` обновляются в том же PR.

View File

@@ -0,0 +1,145 @@
# 02 — ТЗ: устранение петли deploy-staging → development при self-deploy
Work Item: **ORCH-061** · Тип: **BUG** · Приоритет: **P0** · Репо: `orchestrator`
> Это ТЗ фиксирует **требования и контракты**, которые должна удовлетворить
> реализация. Конкретный архитектурный механизм (направление (а), (б) или оба;
> где именно разместить логику) выбирает архитектор в ADR (`06-adr/`).
> ТЗ намеренно не предписывает дизайн, но задаёт инварианты и границы изменений.
---
## 1. Затронутые модули `src/` и артефакты
Прямо относящиеся к дефекту (для контекста; точечный набор правок — за архитектором):
| Файл | Роль в дефекте |
|------|----------------|
| `scripts/staging_check.py` | e2e-suite; C9a (branch) / C9b (analyst job) дают ложный FAIL в sandbox; exit-code управляет вердиктом deployer. |
| `src/qg/checks.py``check_staging_status`, `_parse_staging_status` | гейт ребра `deploy-staging→deploy`; читает `staging_status:` из `15-staging-log.md`. |
| `src/stage_engine.py``advance_stage`, `_handle_qg_failure_rollbacks` | откат `deploy-staging→development` при FAILED (ветка `agent=="deployer" and qg=="check_staging_status"`). |
| `src/agents/launcher.py``_handle_completion`/`_try_advance_stage` | пост-ран git-commit; лог «no changes to commit»; обработка deployer-стадий. |
| `src/self_deploy.py` | Phase A/B/C исполняемого self-deploy (контекст продвижения `deploy`). |
| `src/config.py` | место для kill-switch/настроек нового поведения (если потребуется). |
| `.openclaw/agents/deployer.md` | инструкция deployer о написании вердикта; обновить при смене контракта. |
| `docs/operations/STAGING_CHECK.md`, `docs/architecture/README.md`, `CHANGELOG.md` | golden-source документация (обновить в том же PR). |
## 2. Функциональные требования
### FR-1 — Нет петли на корректном self-deploy
Для self-hosting `orchestrator`, при корректном состоянии (реальный pipeline в
порядке, staging-стенд здоров), задача проходит `deploy-staging → deploy → done`
**без отката** `deploy-staging → development` и **без ручного вмешательства**.
### FR-2 — Ложный (инфраструктурный) FAIL не вызывает откат
Ложное падение `staging_check` в sandbox, вызванное **исключительно** отсутствием
sandbox-настроек (например, C9a/C9b при ненастроенных bot-токенах SANDBOX), не
приводит к `staging_status: FAILED` → откату. Должно быть реализовано одним из
способов (выбор — ADR):
- **(а)** sandbox-инфраструктура приведена в состояние, при котором C9a/C9b
проходят честно (10/10); и/или
- **(б)** вердикт staging-гейта перестаёт зависеть от заведомо инфраструктурных
(не пайплайновых) проверок — например, осознанный allowlist/threshold
«известных sandbox-инфра» проверок, отделённый от реальных pipeline-проверок.
> Любой механизм по FR-2 **обязан** сохранить FR-4 (реальный провал ловится).
### FR-3 — «no changes» на action-стадии не есть недовыполнение
На стадиях деплоя (`deploy-staging`, `deploy`) для self-deploy отсутствие
git-изменений (`no changes to commit`) **не** трактуется как недовыполнение и
**не** приводит к откату/блокировке. Критерий успеха action-стадии = успешный
exit агента/хука + доказанный health/staging-вердикт, а **не** наличие нового
коммита.
### FR-4 — Реальный регресс по-прежнему откатывается (страховка цела)
- Настоящий провал реальных pipeline-проверок staging → `staging_status: FAILED`
→ откат `deploy-staging → development` (как сейчас).
- Настоящий провал прод-деплоя (`deploy_status: FAILED`, БАГ-8) → откат
`deploy → development` (как сейчас).
- Ослабления страховки быть не должно: «зелёный по умолчанию» при недоступности
проверок запрещён (fail-closed для реальных проверок сохраняется).
### FR-5 — Условность self-hosting сохранена
Изменения активны **только** для self-hosting пути
(`is_self_hosting_repo` / `self_deploy_applies`). Для прочих репозиториев
поведение `check_staging_status` (no-op N/A) и стадии деплоя — **без изменений**.
### FR-6 — Управляемость (kill-switch)
Любое новое поведение (толерантность к инфра-FAIL и/или отвязка от git-changes)
закрыто отдельным флагом конфигурации (по образцу `merge_gate_enabled`,
`image_freshness_enabled`, `self_deploy_enabled`), с безопасным дефолтом и
возможностью мгновенно вернуть прежнее поведение без передеплоя кода-логики.
### FR-7 — Наблюдаемость
Срабатывание нового поведения (например, «staging_check: проигнорирован
инфра-FAIL C9a/C9b» или «action-стадия: no-changes ожидаемо») логируется явной
строкой и при необходимости отражается в Plane-комментарии/Telegram, чтобы
оператор отличал «реальный зелёный» от «зелёного с допущением».
## 3. Изменения API
API эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) — **без изменений**.
Допускается расширение снапшота `GET /queue` диагностическим полем (опционально,
по решению архитектора) — без удаления/переименования существующих ключей.
## 4. Изменения схемы БД
**Нет.** Схема (`events`, `tasks`, `agent_runs`, `jobs`) не меняется. Любое
restart-safe состояние (если потребуется) — через существующие паттерны
(sentinel-файлы / поля `jobs.task_content`), без миграций.
## 5. Контракты, которые НЕЛЬЗЯ менять
- `STAGE_TRANSITIONS` (порядок и состав стадий) и `get_previous_stage`.
- Состав/семантика `QG_CHECKS` как реестра; frontmatter-контракты
`staging_status:` (`15-staging-log.md`) и `deploy_status:` (`14-deploy-log.md`) —
читаются ТОЛЬКО из YAML-frontmatter, значения `SUCCESS|FAILED`.
- Откатные контракты БАГ-8 (`deploy→development`) и ORCH-35
(`deploy-staging→development`) для **реальных** провалов.
- Контракт exit-code хука деплоя (`0/1/2`) и `map_exit_code_to_status`.
- Поведение для не-self-hosting репозиториев.
## 6. Требования к новым/изменённым QG checks
- Если выбран механизм толерантности (FR-2 вариант б), он реализуется **внутри**
существующего пути `check_staging_status` / staging-вердикта (не новая стадия),
по образцу условности ORCH-35; контракт «never-raise» сохраняется.
- Любая новая проверка/под-чек регистрируется в `QG_CHECKS` и покрывается
снапшот-тестом реестра (`tests/test_qg_registry_snapshot.py`).
## 7. Требования к staging_check.py (если затрагивается)
- Если выбран механизм классификации проверок (FR-2 вариант б через suite),
e2e-проверки, заведомо зависящие от sandbox-инфраструктуры (C9a/C9b и связанные),
должны быть **отличимы** (по метке/категории) от реальных pipeline-проверок,
чтобы вердикт и/или exit-code мог их учитывать осознанно. Прежний дефолтный
режим (`stub`/`full-real`) и существующие проверки A/B сохраняются.
- Никакого «всегда 0»: реальный провал реальных проверок обязан давать ненулевой
exit-code / FAIL-категорию.
## 8. Требования к pipeline-артефактам
- Стадия деплоя по-прежнему производит машинный вердикт-артефакт
(`15-staging-log.md` / `14-deploy-log.md`) с корректным frontmatter.
- Артефакты, обновляемые по pipeline в этом PR: `docs/architecture/README.md`
(раздел про staging-гейт/self-deploy — отметить ORCH-061),
`docs/operations/STAGING_CHECK.md` (поведение C9a/C9b и/или sandbox-настройка),
`CHANGELOG.md`, при изменении контракта — `.openclaw/agents/deployer.md`.
- ADR: `docs/work-items/ORCH-061/06-adr/ADR-001-*.md` (решение по направлению/механизму).
## 9. Нефункциональные требования
- **Безопасность self-hosting:** реализация НЕ перезапускает/не роняет прод 8500
в рамках задачи; сборки/recreate — только staging (8501).
- **Идемпотентность / restart-safe:** новое поведение переживает рестарт инстанса.
- **never-raise:** дефект-исправляющая логика не должна пробрасывать исключения в
`advance_stage` (по образцу merge-gate / image-freshness).
- **Обратная совместимость:** при выключенном флаге (FR-6) — прежнее поведение 1:1.
- **Тестируемость:** «чистая» вердикт-логика выделяется так, чтобы покрываться
unit-тестами без live staging/docker.
## 10. Зависимости и связанные задачи
- ORCH-35 (условный staging-гейт, ADR-0003), ORCH-36 (исполняемый self-deploy,
ADR-0007), ORCH-58 (провенанс staging-образа), ORCH-60 (skip escalated/Blocked).
- Блокирует: ORCH-54 (автономное внедрение).

View File

@@ -0,0 +1,90 @@
# 03 — Критерии приёмки: ORCH-061
Work Item: **ORCH-061** · Тип: **BUG** · Приоритет: **P0**
Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Критерии outcome-ориентированы
(не предписывают механизм); реализация может удовлетворить FR-2 направлением (а), (б) или обоими.
---
## AC-1 — Автономный проход self-deploy без петли (главный критерий)
- **PASS:** для self-hosting `orchestrator` задача в состоянии `deploy-staging`
при здоровом стенде и корректном pipeline продвигается `deploy-staging → deploy`
(далее по штатному approve → `done`) **без** отката на `development` и **без**
ручного вмешательства в шаги staging/merge/retag/deploy.
- **FAIL:** наблюдается хотя бы один автоматический откат `deploy-staging → development`
при отсутствии реального регресса, либо для прохода требуется ручной шаг.
## AC-2 — Ложный инфраструктурный FAIL не откатывает
- **PASS:** прогон, где **единственные** падения — заведомо sandbox-инфраструктурные
(C9a branch-not-found / C9b analyst-job-not-in-queue при ненастроенных bot-токенах
SANDBOX), а все реальные pipeline-проверки зелёные, приводит к
`staging_status: SUCCESS` (или эквивалентному «не-FAILED») → **нет** отката.
- **FAIL:** такой прогон даёт `staging_status: FAILED` → откат `deploy-staging → development`.
## AC-3 — Реальный провал staging по-прежнему откатывает (страховка цела)
- **PASS:** прогон с провалом **реальной** pipeline-проверки (не инфра-исключение)
даёт `staging_status: FAILED` → откат `deploy-staging → development` +
`set_issue_blocked`/нотификации (как сейчас, ORCH-35).
- **FAIL:** реальный провал staging проходит как успех / задача доходит до `deploy`.
## AC-4 — «no changes to commit» на action-стадии не есть недовыполнение
- **PASS:** на стадиях `deploy-staging`/`deploy` для self-deploy отсутствие
git-изменений не вызывает откат/блокировку; продвижение определяется успешным
exit + health/staging-вердиктом.
- **FAIL:** отсутствие коммита на стадии деплоя приводит к откату/недопродвижению.
## AC-5 — Реальный провал прод-деплоя по-прежнему откатывает (БАГ-8 цел)
- **PASS:** `deploy_status: FAILED` (exit-code хука ≠ 0) → откат `deploy → development`
+ `set_issue_blocked` + release merge-lease + clear deploy-state (как сейчас).
- **FAIL:** провал прод-деплоя проходит как `done`.
## AC-6 — Условность self-hosting сохранена
- **PASS:** для не-self-hosting репо (`is_self_hosting_repo == False`)
`check_staging_status` остаётся `(True, "Staging gate N/A …")`, стадия деплоя
работает как прежде; поведение этих репо байт-в-байт не изменилось.
- **FAIL:** изменилось поведение для не-self-hosting репозиториев.
## AC-7 — Kill-switch возвращает прежнее поведение
- **PASS:** при выключенном флаге нового поведения (FR-6) система ведёт себя 1:1
как до ORCH-061 (включая прежний откат на инфра-FAIL, если флаг выключен).
- **FAIL:** новое поведение невозможно отключить / выключение не восстанавливает старое.
## AC-8 — Контракты не сломаны
- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter-контракты
`staging_status:`/`deploy_status:` (только YAML, `SUCCESS|FAILED`), exit-code хука
(0/1/2) и `map_exit_code_to_status` — без регресса; снапшот-тест реестра гейтов зелёный.
- **FAIL:** изменены контракты стадий/гейтов/вердиктов или сломан снапшот реестра.
## AC-9 — Схема БД не меняется
- **PASS:** нет миграций; `events`/`tasks`/`agent_runs`/`jobs` без изменений схемы.
- **FAIL:** добавлена/изменена колонка/таблица.
## AC-10 — never-raise
- **PASS:** новая логика в пути `advance_stage`/staging-вердикта при любой внутренней
ошибке (docker/ssh/io/парсинг) даёт безопасный детерминированный вердикт и не
пробрасывает исключение в `advance_stage`.
- **FAIL:** исключение из новой логики всплывает в `advance_stage`/останавливает конвейер.
## AC-11 — Наблюдаемость
- **PASS:** срабатывание нового поведения (игнор инфра-FAIL / ожидаемые no-changes)
даёт явную лог-строку (и при необходимости коммент/Telegram), позволяющую отличить
«честно зелёный» от «зелёного с допущением».
- **FAIL:** новое поведение срабатывает молча, неотличимо от честного зелёного.
## AC-12 — Безопасность self-hosting
- **PASS:** реализация не перезапускает/не роняет прод-контейнер 8500 в рамках
задачи; любые сборки/recreate — только staging (8501).
- **FAIL:** код пути задачи рестартит/собирает прод 8500.
## AC-13 — Документация обновлена (golden source)
- **PASS:** в том же PR обновлены `docs/architecture/README.md`,
`docs/operations/STAGING_CHECK.md` (поведение C9a/C9b и/или sandbox-настройка),
`CHANGELOG.md`, и (при смене контракта) `.openclaw/agents/deployer.md`; заведён
ADR `docs/work-items/ORCH-061/06-adr/ADR-001-*.md`.
- **FAIL:** функционал изменён без обновления документации/ADR.
## AC-14 — Регрессионные тесты зелёные
- **PASS:** `pytest tests/ -q` проходит полностью; новые тесты из `04-test-plan.yaml`
присутствуют и зелёные; существующие staging/deploy/qg/stage_engine тесты не упали.
- **FAIL:** любой тест из плана отсутствует или красный.

View File

@@ -0,0 +1,147 @@
work_item: ORCH-061
title: "BUG: deploy-staging петля — откат на development (self-deploy)"
description: >
План тестов на устранение зацикливания deploy-staging -> development для
self-hosting orchestrator. Покрывает обе подтверждённые причины: (1) ложный
FAILED check_staging_status из-за заведомо инфраструктурных C9a/C9b в sandbox;
(2) трактовку "no changes to commit" на action-стадии как недовыполнения.
Тесты outcome-ориентированы и не предписывают механизм: часть кейсов помечена
как mechanism-dependent (а=sandbox-инфра честно, б=толерантность/отвязка) —
финальный набор подтверждает архитектор в ADR; реализуются тесты под выбранный
механизм. Инвариант страховки (реальный регресс откатывает) и условность
self-hosting проверяются ВСЕГДА.
tests:
# --- Главный сценарий: нет петли ----------------------------------------
- id: TC-01
type: unit
description: >
Корректный self-deploy: при staging_status SUCCESS и пройденном merge/freshness
sub-gate advance_stage(deploy-staging, finished_agent=deployer) продвигает к
deploy (Phase A approval-pending), НЕ откатывает на development. (AC-1)
module: tests/test_stage_engine.py
expected: PASS
- id: TC-02
type: unit
description: >
Регресс-страховка ORCH-35: реальный провал реальной pipeline-проверки ->
staging_status FAILED -> advance_stage откатывает deploy-staging -> development
+ set_issue_blocked. (AC-3)
module: tests/test_stage_engine.py
expected: PASS
# --- Причина №1: ложный инфраструктурный FAIL ---------------------------
- id: TC-03
type: unit
description: >
Классификация проверок staging_check: проверки, заведомо зависящие от
sandbox-инфраструктуры (C9a/C9b), отличимы (метка/категория) от реальных
pipeline-проверок. Чистая логика классификации/вердикта тестируется без
live staging/docker. (AC-2, mechanism-dependent: вариант б)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-04
type: unit
description: >
Вердикт-логика: все реальные проверки PASS, падают ТОЛЬКО известные
sandbox-инфра проверки (C9a/C9b) -> итог не-FAILED (нет ложного отката).
(AC-2)
module: tests/test_qg_checks.py
expected: PASS
- id: TC-05
type: unit
description: >
Вердикт-логика: падает хотя бы одна РЕАЛЬНАЯ pipeline-проверка (помимо инфра)
-> итог FAILED (страховка не ослаблена, fail-closed). (AC-3)
module: tests/test_qg_checks.py
expected: PASS
# --- Причина №2: no changes на action-стадии ----------------------------
- id: TC-06
type: unit
description: >
На action-стадии (deploy-staging/deploy) для self-deploy отсутствие
git-изменений ("no changes to commit") НЕ приводит к откату/недопродвижению;
продвижение определяется exit + вердиктом, а не наличием коммита. (AC-4)
module: tests/test_launcher.py
expected: PASS
- id: TC-07
type: unit
description: >
На code-стадии (development) отсутствие изменений всё ещё обрабатывается
прежним образом (нет ложного "успеха" там, где код должен был измениться) —
изменение FR-3 не протекает на не-action стадии. (AC-4, regression-guard)
module: tests/test_launcher.py
expected: PASS
# --- Условность self-hosting --------------------------------------------
- id: TC-08
type: unit
description: >
Для не-self-hosting репо check_staging_status остаётся (True, "Staging gate
N/A …") и новое поведение НЕ активируется; поведение этих репо неизменно.
(AC-6, FR-5)
module: tests/test_qg.py
expected: PASS
# --- Kill-switch / обратная совместимость -------------------------------
- id: TC-09
type: unit
description: >
При выключенном флаге нового поведения (FR-6) система ведёт себя 1:1 как до
ORCH-061: инфра-FAIL снова приводит к FAILED/откату. Дефолт флага безопасен.
(AC-7)
module: tests/test_config.py
expected: PASS
# --- БАГ-8: реальный провал прод-деплоя ----------------------------------
- id: TC-10
type: unit
description: >
deploy_status FAILED (exit-code хука != 0) -> откат deploy -> development +
set_issue_blocked + release merge-lease + clear deploy-state (БАГ-8 не сломан).
(AC-5)
module: tests/test_deploy_rollback.py
expected: PASS
# --- Контракты / реестр / never-raise -----------------------------------
- id: TC-11
type: unit
description: >
Снапшот реестра QG_CHECKS и STAGE_TRANSITIONS не изменён неожиданно;
frontmatter-контракты staging_status/deploy_status (SUCCESS|FAILED, только
YAML) сохранены. (AC-8)
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-12
type: unit
description: >
never-raise: новая логика staging-вердикта/advance при внутренней ошибке
(io/парсинг/docker/ssh) возвращает безопасный детерминированный вердикт и не
пробрасывает исключение в advance_stage. (AC-10)
module: tests/test_stage_engine.py
expected: PASS
# --- Интеграционный сквозной сценарий ------------------------------------
- id: TC-13
type: integration
description: >
Сквозной self-deploy на тестовой БД: задача deploy-staging при здоровом
стенде с инфра-only недочётами проходит deploy-staging -> deploy (Phase A) ->
(approve) -> deploy финализация SUCCESS -> done, БЕЗ единого отката на
development в логе переходов. (AC-1, AC-4)
module: tests/test_stage_engine.py
expected: PASS
- id: TC-14
type: integration
description: >
Наблюдаемость: при срабатывании нового поведения (игнор инфра-FAIL /
ожидаемые no-changes) присутствует явная лог-строка/диагностика, отличающая
"честно зелёный" от "зелёного с допущением". (AC-11)
module: tests/test_stage_engine.py
expected: PASS

View File

@@ -0,0 +1,222 @@
# ADR-001 — Толерантность staging-вердикта к инфра-FAIL + инвариант «no-changes на action-стадии»
- **Статус:** Accepted
- **Дата:** 2026-06-07
- **Задача:** ORCH-061 (BUG, P0) · Репо: `orchestrator` (self-hosting)
- **Связи:** ORCH-35/adr-0003 (условный staging-гейт), ORCH-36/adr-0007 (исполняемый self-deploy), ORCH-58/adr-0008 (провенанс staging-образа), ORCH-43/adr-0006 (merge-gate); блокирует ORCH-54.
- **Сквозной ADR:** [adr-0009-staging-infra-tolerance](../../../architecture/adr/adr-0009-staging-infra-tolerance.md)
---
## Контекст
На стадии `deploy-staging` self-hosting `orchestrator` зацикливается:
`check_staging_status` даёт FAILED → `_handle_qg_failure_rollbacks` откатывает
`deploy-staging → development` → developer перезапускается → конвейер заново →
снова `deploy-staging` → снова FAILED. Петля жжёт developer-ретраи и LLM-кредиты,
а прод-деплой орка приходится доводить вручную (ORCH-58, ORCH-60). Это прямой
блокер автономного внедрения (ORCH-54).
Подтверждены две независимые причины (BRD §2):
**Причина №1 — ложный FAILED.** `scripts/staging_check.py` в sandbox даёт
8/10 PASS, 2 ложных FAIL на e2e-блоке C:
- **C9a** — ветка не появляется в `orchestrator-sandbox`;
- **C9b** — analyst-job не появляется в staging-очереди.
Оба завязаны на отсутствие sandbox-настроек (bot-аккаунты `ORCH_PLANE_BOT_*` не
добавлены членами SANDBOX-проекта — проект создан после провижининга ботов). Это
**отсутствие инфраструктуры sandbox, а не регресс кода**. Но `staging_check.py`
суммирует `all_ok = passed == total` и делает `sys.exit(1)` при любом FAIL →
deployer пишет `staging_status: FAILED` → откат.
**Причина №2 — «no changes to commit» на action-стадии.** Стадии деплоя по природе
действие (рестарт/retag), а не правка `src/`. Отсутствие git-изменений не должно
трактоваться как недовыполнение; критерий успеха action-стадии — exit0 +
health/staging-вердикт, а не наличие коммита.
### Что есть сейчас в коде (точки дефекта)
- `scripts/staging_check.py`: `Results.summary()``all_ok = passed == total`;
`main()``sys.exit(0 if all_ok else 1)`. Все проверки равнозначны — инфра-FAIL
неотличим от регресса.
- `src/qg/checks.py``check_staging_status` / `_parse_staging_status`: читает
`staging_status:` (SUCCESS|FAILED) из `15-staging-log.md`. Условный (ORCH-35):
для не-self репо → `(True, "Staging gate N/A …")`.
- `src/stage_engine.py``_handle_qg_failure_rollbacks`: ветка
`agent=="deployer" and qg=="check_staging_status"` → откат на `development`.
- `src/agents/launcher.py``_monitor_agent`: ветка «no changes to commit» (строка
~583) **уже** просто логирует и идёт в `_try_advance_stage` (НЕ откатывает).
## Рассмотренные направления (BRD §6)
- **(а) Починить sandbox-инфру** — добавить bot-токены SANDBOX, чтобы C9a/C9b
проходили честно (10/10).
- *Минусы:* хрупко (зависит от членства ботов в Plane-проекте, поддерживается
руками вне кода); не предотвращает структурно будущие инфра-only FAIL;
автономный self-deploy-таск не может надёжно выполнить Plane-admin действия сам.
Не закрывает Причину №1 на уровне инварианта.
- **(б) Отвязать вердикт от заведомо инфраструктурных проверок** — классифицировать
проверки suite и сделать вердикт толерантным к инфра-FAIL, сохранив fail-closed
для реальных проверок.
- *Плюсы:* структурно, юнит-тестируемо (чистая вердикт-логика), управляемо
(kill-switch), наблюдаемо (FR-7); сохраняет страховку (FR-4) по построению.
## Решение
Выбран механизм **(б)** как основной, с явной фиксацией инварианта по Причине №2.
Направление (а) переведено в **необязательное hardening** (см. `07-infra-requirements.md`):
с (б) оно перестаёт быть блокером.
### 1. Классификация проверок + толерантный вердикт (Причина №1, FR-2/FR-4)
Новый **leaf-модуль `src/staging_verdict.py`** — чистая логика, без I/O, контракт
**never-raise**, только stdlib (импортируем и из orchestrator, и из
`staging_check.py`, который уже импортирует `src.*` внутри контейнера — паттерн B6/ORCH-048):
```
REAL = "real" # реальная pipeline-проверка
SANDBOX_INFRA = "sandbox_infra" # заведомо зависит от sandbox-инфры
# Узкий allowlist известных инфра-проверок (по префиксу метки):
SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"})
def classify_check(label: str) -> str:
"""SANDBOX_INFRA если метка начинается с известного инфра-префикса, иначе REAL.
Never-raise: на любом непонятном вводе → REAL (консервативно, fail-closed)."""
def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict:
"""items: список (label, passed: bool, category: str).
real_failed = [REAL-проверки с passed=False]
infra_failed = [SANDBOX_INFRA-проверки с passed=False]
- real_failed непусто -> FAILED, exit 1 (страховка)
- infra_failed непусто и infra_tolerant -> SUCCESS, exit 0 (waived)
- infra_failed непусто и НЕ infra_tolerant -> FAILED, exit 1 (legacy strict)
- иначе -> SUCCESS, exit 0
Never-raise: на битом вводе → консервативный FAILED."""
```
`StagingVerdict` несёт `status` (`"SUCCESS"|"FAILED"`), `exit_code` (`0|1`),
`waived` (список заваиверенных меток) и `summary` (человекочитаемая строка).
**Ключевой инвариант страховки (FR-4):** любая упавшая REAL-проверка ⇒ exit 1 ⇒
FAILED ⇒ откат. В частности C7 (создать issue) и C8 (триггер `/webhook/plane`) —
REAL. Waiver применяется к C9a/C9b **только** когда все REAL-проверки (включая
C7/C8) зелёные. Вход в конвейер по-прежнему валидируется C7/C8; C9a/C9b проверяют
лишь downstream-артефакты, которым нужна sandbox-инфра. Так blast-radius waiver'а
сведён к двум именованным проверкам.
### 2. Правки `scripts/staging_check.py`
- `Results.add(label, passed, detail="", category=None)` — при `category is None`
авто-классификация через `staging_verdict.classify_check(label)`; хранит категорию
в элементе.
- `Results.summary()` печатает разбивку по категориям (REAL / SANDBOX_INFRA).
- `main()`:
- резолвит флаг толерантности `_resolve_tolerance()` (см. ниже);
- `verdict = compute_staging_verdict(results.items, infra_tolerant)`;
- при `verdict.waived` печатает явную строку
`INFRA-WAIVED: <labels> (known sandbox-infra; real checks green)` (FR-7);
- `sys.exit(verdict.exit_code)`.
- `_resolve_tolerance()`: читает `settings.staging_infra_tolerance_enabled` (через
`from src.config import settings` — тот же паттерн, что B6). На ошибке импорта →
**strict (False)** (fail-safe: не вайвить при нечитаемом конфиге) + warning.
Опциональный CLI-флаг `--strict` принудительно выключает толерантность для ручных
«честных» прогонов.
Прежние режимы (`--mode stub|full-real`) и проверки A/B/C7/C8 — без изменений.
«Всегда 0» исключено: упавшая REAL-проверка всегда даёт exit 1 (TRZ §7).
### 3. Kill-switch (FR-6, AC-7)
`src/config.py`:
```python
# ORCH-061: толерантность staging-вердикта к заведомо инфраструктурным FAIL
# (C9a/C9b) в sandbox. True -> упавшие ТОЛЬКО sandbox-инфра проверки вайверятся
# (real-проверки fail-closed). False -> 1:1 прежнее строгое поведение (любой FAIL
# -> staging_status FAILED -> откат). Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED.
staging_infra_tolerance_enabled: bool = True
```
Дефолт **True** (как `merge_gate_enabled` / `image_freshness_enabled` /
`self_deploy_enabled`): инвариант страховки (FR-4) держится независимо от флага —
реальные провалы всё равно fail-closed; флаг существует, чтобы мгновенно вернуть
legacy-строгость без передеплоя кода. Флаг живёт в `.env.staging` контейнера
(`ORCH_` prefix), поэтому достижим скриптом внутри `orchestrator-staging`.
`False` → suite строгий → 1:1 поведение до ORCH-061 (AC-7).
### 4. Что НЕ меняется (контракты, AC-8)
- `check_staging_status` / `_parse_staging_status`**без изменений**: читают
`staging_status:` (только YAML, `SUCCESS|FAILED`). Толерантность реализована
ДО артефакта (в exit-code suite → вердикт deployer), внутри существующего пути
staging-вердикта, не отдельной стадией (TRZ §6).
- **Новый QG-чек НЕ добавляется** → реестр `QG_CHECKS` и снапшот-тест
(`tests/test_qg_registry_snapshot.py`) неизменны (AC-8 / TC-11).
- `STAGE_TRANSITIONS`, `get_previous_stage`, exit-code хука деплоя (0/1/2),
`map_exit_code_to_status`, `check_deploy_status`, БАГ-8 — без изменений.
- Условность self-hosting (AC-6): `staging_check.py` канонически бежит только для
`orchestrator`; `check_staging_status` для не-self репо остаётся
`(True, "Staging gate N/A …")`. Поведение прочих репо байт-в-байт неизменно.
### 5. Инвариант «no-changes на action-стадии» (Причина №2, FR-3/AC-4)
`launcher._monitor_agent` **уже** не откатывает на «no changes to commit» (просто
логирует и идёт в `_try_advance_stage`; продвижение определяется гейтом). ORCH-061:
- **Фиксируем инвариант** как покрытый тестами контракт: на `deploy-staging`/`deploy`
для self-deploy продвижение определяется exit0 + гейт-вердиктом, НИКОГДА наличием
коммита (TC-06).
- **Наблюдаемость (FR-7/AC-11):** в ветке «no changes» логировать явную строку,
отличающую action-стадию (ожидаемо: артефакт-вердикт, не обязательно код) от
code-стадии. Резолв стадии задачи по `(repo, branch)`; при
`stage ∈ {deploy-staging, deploy}` и `self_deploy.self_deploy_applies(repo)`
`staging/deploy: no code changes (expected on action stage)`.
- **Regression-guard (TC-07):** на `development` (code-стадия) поведение «no changes»
неизменно — изменение FR-3 не протекает на не-action стадию.
Изменение минимальное (self-hosting safety, AC-12): не трогает прод-контейнер 8500,
сборки/recreate — только staging (8501).
## Затронутые файлы (для developer)
| Файл | Изменение |
|------|-----------|
| `src/staging_verdict.py` | **новый** leaf-модуль: `classify_check`, `compute_staging_verdict`, `StagingVerdict` (pure, never-raise). |
| `scripts/staging_check.py` | категории в `Results`, вердикт через `staging_verdict`, INFRA-WAIVED-лог, `--strict`. |
| `src/config.py` | флаг `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`). |
| `src/agents/launcher.py` | observability-лог action-stage no-changes (без смены логики продвижения). |
| `.openclaw/agents/deployer.md` | уточнение: exit0 может включать «infra-waived»; контракт `staging_status:` SUCCESS\|FAILED неизменен. |
| `docs/operations/STAGING_CHECK.md` | поведение C9a/C9b, флаг, INFRA-WAIVED, `--strict`. |
| `docs/architecture/README.md` | пометка ORCH-061 в разделе staging-гейта (уже внесена архитектором). |
| `CHANGELOG.md` | запись ORCH-061. |
| `tests/` | TC-01…TC-14 (см. `04-test-plan.yaml`). |
## Последствия
**Плюсы**
- Петля устранена структурно: ложный инфра-FAIL → SUCCESS (waived) → нет отката (G1/G2).
- Страховка цела: любая реальная pipeline-проверка fail-closed → FAILED → откат (G4/FR-4).
- Чистая вердикт-логика юнит-тестируема без live staging/docker (NFR-тестируемость).
- Контракты гейтов/стадий/вердиктов/реестра не тронуты (AC-8); схема БД не меняется (AC-9).
- Мгновенный откат к legacy через kill-switch (AC-7).
- Разблокирует автономный self-deploy (ORCH-54).
**Минусы / ограничения**
- C9a/C9b теперь могут заваиверить **реальный** даунстрим-регресс именно в создании
ветки / постановке analyst-job (узкий риск). Митигировано: waiver только когда C7/C8
и все прочие REAL зелёные; allowlist жёстко = {C9a, C9b}; INFRA-WAIVED логируется и
виден оператору. См. `10-tech-risks.md` (R-1).
- Толерантность скрывает «нездоровье sandbox» как зелёное-с-допущением; отличимо
только по INFRA-WAIVED-логу/комментарию (наблюдаемость обязательна, FR-7).
- Honest 10/10 в sandbox (направление а) остаётся желательным hardening, но не блокером.
## Альтернативы (отклонены)
- **Только (а) — починить sandbox-инфру:** хрупко, не структурно, вне автономной
досягаемости таска. Оставлено как опциональное hardening.
- **«Зелёный по умолчанию» при недоступности проверок:** запрещён FR-4 (fail-closed).
- **Новый QG-чек `check_staging_infra_tolerant`:** избыточно — менял бы реестр
`QG_CHECKS` и снапшот; толерантность лучше живёт в suite/вердикте до артефакта.
- **Толерантность внутри `check_staging_status` через структурный артефакт:**
потребовал бы сменить контракт `15-staging-log.md` и научить deployer писать
per-check категории — больше движущихся частей; отклонено в пользу решения в suite.

View File

@@ -0,0 +1,37 @@
# 07 — Требования к инфраструктуре: ORCH-061
Work Item: **ORCH-061** · Репо: `orchestrator`
Топология/контейнеры/порты **не меняются** (TRZ §3, §9). Self-hosting-безопасность
сохранена: прод-контейнер `orchestrator` (8500) не перезапускается/не роняется в
рамках задачи; любые сборки/recreate — только staging (8501). См.
`docs/operations/INFRA.md`.
## IR-1 — Конфиг-флаг (kill-switch)
Новый флаг `staging_infra_tolerance_enabled` (env
`ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`).
- Должен присутствовать в окружении контейнера **`orchestrator-staging`**
(`.env.staging`), т.к. `scripts/staging_check.py` читает его через
`src.config.settings` при каноническом запуске `docker exec` внутри стенда.
- Для прод-инстанса (`.env`) флаг безвреден (на прод-пути staging-suite не
исполняется), но рекомендуется держать значения консистентными.
- `false` → мгновенный возврат к строгому (legacy) поведению без передеплоя кода.
- Канон секретов/env: значения в `.env`/`.env.staging` на хосте, в гит НЕ
коммитятся; задокументировать ключ в `.env.example` (канон ORCH-9).
## IR-2 — Опциональное hardening sandbox (направление «а», НЕ блокер)
Первопричина ложных C9a/C9b — bot-аккаунты агентов (`ORCH_PLANE_BOT_*`) не добавлены
членами Plane-проекта **SANDBOX** (`8c5a3025-…`), созданного после провижининга
ботов. С выбранным механизмом (б) это перестаёт блокировать конвейер, но честный
10/10 в sandbox желателен:
- Добавить bot-аккаунты агентов членами SANDBOX-проекта в Plane (даст честный
C9b: коммент analyst'а перестанет получать 403; и устранит инфра-причину C9a/C9b).
- Действие — ручное (Plane-admin), вне автоматической досягаемости таска; выполняется
оператором при возможности. После него C9a/C9b проходят честно и waiver не нужен.
- Это hardening, а не требование приёмки ORCH-061 (приёмка — на механизме «б»).
## IR-3 — Без новой инфраструктуры
Новые сервисы/порты/тома/сетевые правила/cron — **не требуются**. Никаких
изменений в `docker-compose.yml`, образах, реестре проектов.

View File

@@ -0,0 +1,20 @@
# 08 — Требования к данным / схеме БД: ORCH-061
Work Item: **ORCH-061** · Репо: `orchestrator`
## DR-1 — Схема БД не меняется (AC-9)
Никаких миграций. Таблицы `events`, `tasks`, `agent_runs`, `jobs` — без изменений
колонок/индексов/таблиц.
## DR-2 — Никакого нового персистентного состояния
Решение (ADR-001) — чистая вердикт-логика (`src/staging_verdict.py`) + конфиг-флаг +
правка exit-code suite. Состояние конвейера не вводится:
- толерантность вычисляется на лету при прогоне `staging_check.py`;
- restart-safe-состояние не требуется (вердикт фиксируется в существующем артефакте
`15-staging-log.md` через прежний контракт `staging_status: SUCCESS|FAILED`).
## DR-3 — Артефакт-контракт неизменен
`15-staging-log.md` по-прежнему несёт frontmatter `staging_status: SUCCESS|FAILED`
(только YAML). `14-deploy-log.md` (`deploy_status:`) — без изменений. Гейты читают
ТОЛЬКО frontmatter. Толерантность реализована ДО записи артефакта (на уровне
exit-code suite → вердикт deployer), поэтому формат и парсинг артефактов не трогаются.

View File

@@ -0,0 +1,26 @@
# 10 — Технические риски: ORCH-061
Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting)
| # | Риск | Вероятн. | Влияние | Митигация |
|---|------|----------|---------|-----------|
| **R-1** | Waiver C9a/C9b маскирует **реальный** регресс именно в создании ветки / постановке analyst-job (ложно-зелёный staging). | Низкая | Высокое | Allowlist жёстко `{C9a, C9b}`; waiver применяется ТОЛЬКО когда ВСЕ REAL-проверки зелёные, включая C7 (создать issue) и C8 (триггер `/webhook/plane`) — вход в конвейер всегда валидируется реально. `INFRA-WAIVED`-строка в логе/комменте делает допущение видимым (FR-7). Honest 10/10 (IR-2) убирает риск совсем. |
| **R-2** | Ослабление страховки: реальный pipeline-FAIL пройдёт как SUCCESS. | Низкая | Критич. | Инвариант `compute_staging_verdict`: любая упавшая REAL → exit1 → FAILED → откат (FR-4/AC-3/TC-05). Покрыто юнит-тестом отдельным кейсом. |
| **R-3** | Флаг не достигает скрипта (читается не из того env) → толерантность «молча» не работает или, наоборот, не выключается. | Средняя | Среднее | Скрипт читает `settings.staging_infra_tolerance_enabled` через `from src.config import settings` — тот же канал, что B6/ORCH-048 (внутри `orchestrator-staging`, env `.env.staging`). На ошибке импорта — fail-safe в strict (False) + warning. Документировать ключ в `.env.staging`/`.env.example` (IR-1). Тест kill-switch (TC-09). |
| **R-4** | Классификатор ошибочно пометит REAL-проверку как SANDBOX_INFRA (расширение allowlist в будущем). | Низкая | Высокое | `classify_check` — узкий префиксный allowlist; добавление новой инфра-метки требует осознанного PR + теста (TC-03). По умолчанию неизвестная метка → REAL (консервативно). |
| **R-5** | Регресс совместимости: изменение exit-code suite ломает другие потребители (deploy-хук, ручные прогоны). | Низкая | Среднее | Exit-code семантика сохранена для honest-прогонов (всё PASS → 0; реальный FAIL → 1). Меняется лишь трактовка «только инфра-FAIL» (теперь 0 при толерантности). Deployer-маппинг exit0→SUCCESS/≠0→FAILED не меняется; deployer.md уточняется. `--strict` даёт ручной honest-режим. |
| **R-6** | never-raise нарушен: исключение из `staging_verdict`/классификатора. | Низкая | Среднее | `src/staging_verdict.py` — pure, без I/O; контракт never-raise (на битом вводе → консервативный FAILED). Логика вне пути `advance_stage` (исполняется в subprocess suite), поэтому в конвейер исключение структурно не попадает (AC-10). |
| **R-7** | FR-3: правка no-changes протекает на code-стадию (`development`) и маскирует «developer ничего не сделал». | Низкая | Среднее | Observability-строка ограничена `stage ∈ {deploy-staging, deploy}` и `self_deploy_applies(repo)`; логика продвижения launcher не меняется. Regression-guard TC-07. |
| **R-8** | Self-hosting: правки случайно затронут прод 8500 / не-self репо. | Низкая | Критич. | Изменения только на self-deploy-пути и в suite (бежит лишь для `orchestrator`-staging). `check_staging_status` для не-self репо неизменно `(True, N/A)` (AC-6/TC-08). Сборки/recreate — только 8501. Прод 8500 не трогается (AC-12). |
| **R-9** (realized) | Та же петля `deploy-staging → development` по ВТОРОЙ причине: `docker build` staging-образа падает (rc=1), т.к. `Dockerfile` `COPY data/ ./data/` ссылается на gitignore-каталог, отсутствующий в build-context воркти. Всплыло, когда waiver C9a/C9b впервые пропустил конвейер до пересборки образа (`check_staging_image_fresh`, ORCH-058). | — (произошло) | Высокое | `COPY data/ ./data/``RUN mkdir -p /app/data`. `data/` приходит через compose bind-mount, в образ запекать нечего. Инвариант: `Dockerfile` не `COPY` gitignore-путей (иначе сборка из воркти ломается). Гард — `tests/test_dockerfile_worktree_buildable.py`. |
## Контрактные инварианты (не нарушать)
- `STAGE_TRANSITIONS`, `get_previous_stage` — без изменений.
- Реестр `QG_CHECKS` — без изменений; новый QG-чек НЕ вводится (снапшот-тест зелёный, TC-11).
- Frontmatter `staging_status:` / `deploy_status:` — только YAML, `SUCCESS|FAILED`.
- Exit-code хука деплоя (0/1/2) и `map_exit_code_to_status` — без изменений.
- БАГ-8 (`deploy → development`) и ORCH-35 (`deploy-staging → development`) для
**реальных** провалов — сохранены.
- Схема БД — без миграций.
# ci-rerun 2026-06-07T13:08:38Z after disk cleanup
# ci-rerun gitea-restarted 2026-06-07T13:14:14Z

View File

@@ -0,0 +1,88 @@
---
type: review
work_item_id: ORCH-061
verdict: APPROVED
version: 1
---
# Review ORCH-061
## Summary
Исправление петли `deploy-staging → development` при self-hosting self-deploy.
Реализовано Direction (б) из ADR-001: классификация staging-проверок на `REAL`
(fail-closed) и `SANDBOX_INFRA` (узкий allowlist `{C9a, C9b}`, waivable) +
толерантный-но-fail-closed вердикт.
Реализация **полностью соответствует ТЗ (02-trz.md), критериям приёмки
(03-acceptance-criteria.md) и ADR-001**. Все контракты сохранены, документация
обновлена в том же PR, тесты зелёные.
Проверено по осям:
- **Соответствие ТЗ:** FR-1…FR-7 закрыты. Новый leaf-модуль
`src/staging_verdict.py` (stdlib-only, never-raise), флаг
`staging_infra_tolerance_enabled` (kill-switch, default True), observability
через `INFRA-WAIVED:`/`VERDICT:` и `action_stage_no_changes_note`.
- **Соответствие ADR-001:** механизм, allowlist `{C9a, C9b}`, fail-closed для
REAL, waiver только когда все REAL (вкл. C7/C8) зелёные, `--strict`,
`_resolve_tolerance` (fail-safe → strict при нечитаемом конфиге) — реализовано
ровно как в «Решении» ADR. Затронутые файлы совпадают с таблицей ADR.
- **Контракты (AC-8):** `src/qg/checks.py` (`check_staging_status`/
`_parse_staging_status`), `src/stages.py` (`STAGE_TRANSITIONS`, `QG_CHECKS`)
**не изменены** (подтверждено `git diff`). Толерантность живёт в suite ДО
записи артефакта; новый QG-чек не вводится; реестр-снапшот цел.
- **Схема БД (AC-9):** миграций нет, флаг — только конфиг.
- **never-raise (AC-10):** `compute_staging_verdict`/`classify_check`/
`_coerce_item`/`action_stage_no_changes_note` ловят всё и деградируют в
консервативный FAILED/None. Покрыто TC-12.
- **Условность self-hosting / страховка (AC-3/AC-5/AC-6):** rollback на реальном
FAIL сохранён (`tests/test_stage_engine.py` TestStaging*), поведение не-self
репо неизменно.
- **Тесты (AC-14):** `pytest tests/ -q`**670 passed**. ORCH-061 покрытие:
TC-04 (infra waived → SUCCESS), TC-05 (REAL fail → FAILED), TC-09 (strict),
TC-12 (garbage never-raise), TC-06/TC-07 (action-stage no-changes note),
non-self репо.
- **Безопасность self-hosting (AC-12):** код задачи не трогает прод 8500;
сборки/recreate — вне пути этой логики.
Примечание по диффу: при просмотре `git diff main...HEAD` появлялись файлы
ORCH-060 (reconciler, plane_sync, config reconcile-флаги). Это артефакт
**устаревшего локального ref `main`**`origin/main` уже содержит ORCH-060
(merge `d4c6cc0`, PR #60). Истинный `git diff origin/main...HEAD` — чистый
ORCH-061. Бандлинга чужого work-item нет.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- [ ] **Стрэй-файлы агентного скрэтча закоммичены в репо:** `.task.md`,
`.task-arch.md`, `.task-dev.md` (хэндофф-файлы стадий analysis/architecture/
development) попали в коммит и не покрыты `.gitignore`. Это засоряет репо и
будет повторяться каждый прогон. Рекомендация: удалить из индекса и добавить
`.task*.md` в `.gitignore`. Не функциональный дефект — на корректность
ORCH-061 не влияет.
## Документация
Обновлена в том же PR (golden source, AC-13) — соответствует требованию CLAUDE.md:
- `docs/architecture/README.md` — раздел staging-гейта помечен ORCH-061 +
статус в футере.
- `docs/architecture/adr/adr-0009-staging-infra-tolerance.md` — сквозной ADR
заведён; `adr/README.md` обновлён.
- `docs/operations/STAGING_CHECK.md` — поведение C9a/C9b, флаг, INFRA-WAIVED,
`--strict`.
- `.openclaw/agents/deployer.md` — уточнён контракт exit0/INFRA-WAIVED (контракт
`staging_status: SUCCESS|FAILED` неизменён).
- `.env.example``ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (канон, секреты не
коммитятся).
- `CHANGELOG.md` — запись ORCH-061.
- ADR per-work-item `docs/work-items/ORCH-061/06-adr/ADR-001-*.md` — присутствует.
Документация полная и точная; расхождений с кодом не выявлено.

View File

@@ -0,0 +1,85 @@
---
type: test-report
work_item_id: ORCH-061
result: PASS
---
# Test Report — ORCH-061
BUG: устранение петли `deploy-staging → development` при self-hosting self-deploy.
Реализован Direction (б) из ADR-001: классификация staging-проверок на `REAL`
(fail-closed) и `SANDBOX_INFRA` (allowlist `{C9a, C9b}`, waivable) + толерантный,
но fail-closed вердикт (`src/staging_verdict.py`), kill-switch
`staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-07T13:19Z
- Ветка: `feature/ORCH-061-bug-deploy-staging-development`
- Review verdict: APPROVED (12-review.md)
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| GET /health | HTTP 200 `{"status":"ok","service":"orchestrator"}` |
| GET /status | HTTP 200 (ORCH-061 в стадии `testing`) |
| GET /queue | HTTP 200 (counts/resilience/reconcile present) |
> Прод-контейнер 8500 не перезапускался и не трогался (self-hosting safety, AC-12).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | Корректный self-deploy: staging SUCCESS → advance к deploy, без отката | `test_stage_engine.py::test_tc01_healthy_self_deploy_advances_no_rollback` | PASS |
| TC-02 | Страховка ORCH-35: реальный FAIL → откат deploy-staging→development | `test_stage_engine.py::test_tc02_real_staging_failed_rolls_back` | PASS |
| TC-03 | Классификация REAL vs SANDBOX_INFRA (C9a/C9b отличимы) | `test_staging_check_b6.py::test_tc03_classify_infra_checks` (+ records/override/strict) | PASS |
| TC-04 | Падают только C9a/C9b → итог не-FAILED (нет ложного отката) | `test_qg_checks.py::test_tc04_only_infra_failures_waived_to_success` | PASS |
| TC-05 | Падает реальная pipeline-проверка → FAILED (fail-closed) | `test_qg_checks.py::test_tc05_any_real_failure_fails_closed` (+ `_even_alone`) | PASS |
| TC-06 | no-changes на action-стадии (deploy-staging/deploy) не есть недовыполнение | `test_launcher.py::test_tc06_deploy_staging_self_deploy_returns_note` / `test_tc06_deploy_self_deploy_returns_note` | PASS |
| TC-07 | regression-guard: на code-стадии (development) поведение прежнее | `test_launcher.py::test_tc07_development_stage_returns_none` | PASS |
| TC-08 | Не-self-hosting репо: check_staging_status остаётся (True, "N/A …") | `test_qg.py` (no-op N/A) | PASS |
| TC-09 | Kill-switch выкл → 1:1 прежнее строгое поведение, безопасный дефолт | `test_qg_checks.py::test_tc09_infra_failure_strict_mode_fails_closed` + `test_config.py::test_staging_infra_tolerance_*` | PASS |
| TC-10 | БАГ-8: deploy_status FAILED → откат deploy→development | `test_deploy_rollback.py` | PASS |
| TC-11 | Снапшот QG_CHECKS / STAGE_TRANSITIONS не изменён; frontmatter-контракты целы | `test_qg_registry_snapshot.py` | PASS |
| TC-12 | never-raise: вердикт-логика при мусоре → безопасный детерминированный FAILED | `test_qg_checks.py::test_tc12_compute_verdict_never_raises_on_garbage` + `test_stage_engine.py::test_tc12_retry_and_rollback_behavior_unchanged` | PASS |
| TC-13 | Сквозной self-deploy: deploy-staging→deploy→done без единого отката | `test_stage_engine.py::test_tc13_end_to_end_self_deploy_no_single_rollback` | PASS |
| TC-14 | Наблюдаемость: «зелёный с допущением» отличим от честного зелёного | `test_stage_engine.py::test_tc14_waived_green_distinguishable_from_honest_green` | PASS |
Все 14 TC присутствуют и зелёные.
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
| AC | Критерий | Покрытие | Статус |
|----|----------|----------|--------|
| AC-1 | Проход self-deploy без петли | TC-01, TC-13 | PASS |
| AC-2 | Инфра-FAIL (C9a/C9b) не откатывает | TC-03, TC-04 | PASS |
| AC-3 | Реальный провал staging откатывает | TC-02, TC-05 | PASS |
| AC-4 | no-changes на action-стадии ≠ недовыполнение | TC-06, TC-07 | PASS |
| AC-5 | БАГ-8: провал прод-деплоя откатывает | TC-10 | PASS |
| AC-6 | Условность self-hosting сохранена | TC-08 | PASS |
| AC-7 | Kill-switch возвращает прежнее поведение | TC-09 | PASS |
| AC-8 | Контракты не сломаны (реестр/frontmatter/exit-code) | TC-11 | PASS |
| AC-9 | Схема БД не меняется | миграций нет (флаг — конфиг) | PASS |
| AC-10 | never-raise | TC-12 | PASS |
| AC-11 | Наблюдаемость (INFRA-WAIVED / waived list) | TC-14 | PASS |
| AC-12 | Безопасность self-hosting (прод 8500 не трогается) | smoke + код пути | PASS |
| AC-13 | Документация обновлена (golden source) | подтверждено в 12-review.md | PASS |
| AC-14 | Регрессионные тесты зелёные | `pytest tests/ -q` → 670 passed | PASS |
## Вывод pytest
```
$ python -m pytest tests/ -v --tb=short
...
======================= 670 passed, 1 warning in 12.15s ========================
```
Единственный warning — PydanticDeprecatedSince20 (class-based Config в `src/config.py`),
не относится к ORCH-061, существовал ранее.
## Итог
**PASS** — полный регресс зелёный (670 passed, 0 failed), все 14 TC из плана и все 14
критериев приёмки выполнены. Страховка цела (реальный регресс staging и БАГ-8
откатывают), условность self-hosting сохранена, kill-switch работает, never-raise
покрыт. Smoke API prod — 200, прод-контейнер не затронут.
Задача готова к переходу на стадию **deploy-staging**.

View File

@@ -0,0 +1,68 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T13:27:06+00:00
base_url: http://localhost:8501
---
# Staging Gate Log — ORCH-061
Staging test suite completed against the live `orchestrator-staging` stand (8501).
**Verdict: SUCCESS (exit 0)** — all REAL pipeline checks green; the two known
sandbox-infra checks (C9a/C9b) were FAILED-but-**waived** by the ORCH-061
infra-tolerance logic. This is exactly the behaviour this work item ships.
## Observability — INFRA-WAIVED
```
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
```
## Result breakdown
```
RESULT: 8/10 checks PASS
REAL failed : none
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
tolerance: staging_infra_tolerance_enabled=True
```
| Check | Category | Result |
|-------|----------|--------|
| A1 GET /health → 200 status=ok | REAL | PASS |
| A2 GET /queue → 200 counts/max_concurrency/resilience | REAL | PASS |
| A3 ORCH_STAGING=true (not prod) | REAL | PASS |
| B4 Plane: sandbox project accessible | REAL | PASS |
| B5 Gitea: orchestrator-sandbox accessible, push=true | REAL | PASS |
| B6 Registry: sandbox present, prod ET/ORCH absent | REAL | PASS |
| C7 Create issue in Plane SANDBOX | REAL | PASS |
| C8 Trigger pipeline via /webhook/plane | REAL | PASS |
| C9a Branch appears in orchestrator-sandbox | SANDBOX_INFRA | FAIL (waived) |
| C9b Analyst job enqueued in staging queue | SANDBOX_INFRA | FAIL (waived) |
C9a/C9b fail because the SANDBOX bot accounts are not yet members of the Plane
sandbox project, so steps 6+ of the pipeline are unreachable **in the sandbox**
an infrastructure limitation, not a pipeline regression (see
`docs/operations/STAGING_CHECK.md`). All REAL checks (incl. C7/C8) are green, so
the waiver applies and the gate advances.
## Run note (self-hosting bootstrap)
The canonical bind-mounted script path (`/repos/orchestrator/scripts/staging_check.py`)
and the running `orchestrator-staging` image both predate ORCH-061 (no
`src/staging_verdict.py`, tolerance flag absent), because ORCH-061 modifies the
staging gate itself. To produce a faithful verdict for the **validated commit**,
the gate was executed from the validated worktree inside the staging container:
```
docker exec orchestrator-staging \
env PYTHONPATH=<worktree>:/app \
python3 <worktree>/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
`PYTHONPATH=<worktree>:/app` keeps B6's registry read sourced from the running
staging instance's own env (sandbox-only registry — ORCH-048/ADR-001), while
loading the shipped `staging_verdict` logic and `staging_infra_tolerance_enabled`
config. This exercises the live staging endpoints AND the exact verdict logic
being shipped. EXEC EXIT CODE: 0.

View File

@@ -51,6 +51,46 @@ import datetime
import urllib.request
import urllib.error
import urllib.parse
from collections import namedtuple
# ---------------------------------------------------------------------------
# ORCH-061: pure staging-verdict logic (classification + infra-tolerant verdict).
# Imported from src.staging_verdict — a stdlib-only leaf, safe to import inside
# the orchestrator-staging container (PYTHONPATH=/app, pattern B6 / ORCH-048).
# Guarded so the suite still runs (in strict mode) if src is somehow unimportable
# from a host invocation; the fallback NEVER yields a silent green (fail-closed).
# ---------------------------------------------------------------------------
try:
from src.staging_verdict import ( # type: ignore
classify_check as _classify_check,
compute_staging_verdict as _compute_staging_verdict,
REAL as _REAL,
SANDBOX_INFRA as _SANDBOX_INFRA,
)
except Exception: # pragma: no cover - exercised only on a broken host import
_classify_check = None
_compute_staging_verdict = None
_REAL = "real"
_SANDBOX_INFRA = "sandbox_infra"
_FallbackVerdict = namedtuple("StagingVerdict", "status exit_code waived summary")
def _classify(label: str) -> str:
"""Classify a check label via staging_verdict; fail-closed to REAL if absent."""
if _classify_check is not None:
return _classify_check(label)
return _REAL
def _verdict(items, infra_tolerant: bool):
"""Compute the suite verdict via staging_verdict; strict fail-closed fallback."""
if _compute_staging_verdict is not None:
return _compute_staging_verdict(items, infra_tolerant)
failed = [lbl for (lbl, ok, _cat) in items if not ok]
if failed:
return _FallbackVerdict("FAILED", 1, [], f"FAILED (strict fallback): {failed}")
return _FallbackVerdict("SUCCESS", 0, [], "SUCCESS (strict fallback): all green")
# ---------------------------------------------------------------------------
# Colour helpers
@@ -152,23 +192,47 @@ def _sign_payload(secret: str, body: bytes) -> str:
class Results:
def __init__(self):
# _items keeps the (label, passed, detail) 3-tuple shape that existing
# ORCH-048 B6 tests unpack — categories live in a PARALLEL list so the
# public tuple contract is unchanged.
self._items: list[tuple[str, bool, str]] = [] # (label, passed, detail)
self._categories: list[str] = [] # ORCH-061: REAL | SANDBOX_INFRA
def add(self, label: str, passed: bool, detail: str = ""):
def add(self, label: str, passed: bool, detail: str = "", category: str | None = None):
# ORCH-061: every check carries a category. None -> auto-classify by label
# (C9a/C9b -> SANDBOX_INFRA, everything else -> REAL). Fail-closed: an
# unknown label is REAL, so it still counts toward the safety net.
if category is None:
category = _classify(label)
self._items.append((label, passed, detail))
self._categories.append(category)
line = _ok(label) if passed else _fail(label)
if detail:
line += f" [{detail}]"
print(line)
def categorized_items(self) -> list[tuple[str, bool, str]]:
"""Rows as ``(label, passed, category)`` for ``compute_staging_verdict``."""
return [
(label, passed, cat)
for (label, passed, _detail), cat in zip(self._items, self._categories)
]
def summary(self) -> bool:
passed = sum(1 for _, ok, _ in self._items if ok)
total = len(self._items)
all_ok = passed == total
colour = _GREEN if all_ok else _RED
# ORCH-061: per-category breakdown so an operator can tell a REAL failure
# (regression — fail-closed) from a SANDBOX_INFRA one (waivable).
rows = self.categorized_items()
real_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _REAL]
infra_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _SANDBOX_INFRA]
print()
print(f"{_BOLD}{'='*60}{_RESET}")
print(f"{colour}{_BOLD} RESULT: {passed}/{total} checks PASS{_RESET}")
print(f" REAL failed : {real_fail or 'none'}")
print(f" SANDBOX_INFRA failed: {infra_fail or 'none'}")
print(f"{_BOLD}{'='*60}{_RESET}")
return all_ok
@@ -637,6 +701,28 @@ def _cleanup(plane_base, workspace, gitea_base, plane_headers, gitea_headers,
# Main
# ---------------------------------------------------------------------------
def _resolve_tolerance(cli_strict: bool) -> bool:
"""Resolve whether the infra-FAIL waiver is active (ORCH-061).
Precedence: an explicit ``--strict`` CLI flag forces it OFF (for honest manual
runs). Otherwise read ``settings.staging_infra_tolerance_enabled`` from the
running instance's own config (same pattern as B6's src.* import inside the
container). On ANY import/read error -> STRICT (False): we never waive when the
config is unreadable (fail-safe), and we say so.
"""
if cli_strict:
print(_info("tolerance: DISABLED via --strict (honest run)"))
return False
try:
from src.config import settings # noqa: WPS433 - lazy, mirrors B6
enabled = bool(settings.staging_infra_tolerance_enabled)
print(_info(f"tolerance: staging_infra_tolerance_enabled={enabled}"))
return enabled
except Exception as e:
print(_info(f"tolerance: config unavailable, defaulting to STRICT: {e}"))
return False
def main():
parser = argparse.ArgumentParser(
description="Live staging-stand check suite (ORCH-33)"
@@ -656,6 +742,15 @@ def main():
"full-real: also wait for the analyst agent (slow, costs credits)."
),
)
parser.add_argument(
"--strict",
action="store_true",
help=(
"ORCH-061: force strict suite — disable the sandbox-infra (C9a/C9b) "
"FAIL waiver even if staging_infra_tolerance_enabled=True. Use for an "
"honest 10/10 run once the sandbox bot accounts are provisioned."
),
)
args = parser.parse_args()
base = args.base_url.rstrip("/")
@@ -673,8 +768,23 @@ def main():
block_b(results)
block_c(base, results, args.mode)
all_ok = results.summary()
sys.exit(0 if all_ok else 1)
results.summary()
# ORCH-061: the EXIT CODE (which drives the deployer's staging_status verdict)
# comes from the infra-tolerant verdict, NOT a raw passed==total count. A run
# whose only failures are known sandbox-infra checks (C9a/C9b) is waived to
# exit 0 when tolerance is on; ANY real check failure still exits 1 (FR-4).
infra_tolerant = _resolve_tolerance(args.strict)
verdict = _verdict(results.categorized_items(), infra_tolerant)
if verdict.waived:
# FR-7 observability: make "green with an allowance" distinguishable from
# an honest green in the logs / captured deployer output.
print(f"{_YELLOW}{_BOLD}INFRA-WAIVED:{_RESET} "
f"{', '.join(verdict.waived)} "
f"(known sandbox-infra; real checks green)")
print(f"{_BOLD}VERDICT:{_RESET} {verdict.status} "
f"(exit {verdict.exit_code}) — {verdict.summary}")
sys.exit(verdict.exit_code)
if __name__ == "__main__":

View File

@@ -20,6 +20,33 @@ logger = logging.getLogger("orchestrator.launcher")
# never passed through to the CLI.
VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
# ORCH-061: action stages whose success is an ACTION (restart/retag), not a src
# edit — so "no changes to commit" is EXPECTED there, not under-delivery (FR-3).
_ACTION_STAGES = frozenset({"deploy-staging", "deploy"})
def action_stage_no_changes_note(stage, repo) -> str | None:
"""ORCH-061 (FR-3 / FR-7): observability for an empty diff on an action stage.
The ``deploy-staging`` / ``deploy`` stages are actions (restart / retag), not
code edits, so the post-run "no changes to commit" is the NORMAL case there —
advancement is decided by the agent exit-code + the staging/deploy gate verdict,
NEVER by the presence of a commit (FR-3 / AC-4). This is a PURE decision used
only to emit an explicit log line distinguishing an expected action-stage no-op
from a code-stage no-op; it has no effect on stage advancement.
Returns an explicit note string when the empty diff is expected (an action
stage of a self-deploy repo), else ``None``. Never raises.
"""
try:
if stage in _ACTION_STAGES:
from ..self_deploy import self_deploy_applies
if self_deploy_applies(repo):
return f"{stage}: no code changes (expected on action stage)"
return None
except Exception: # noqa: BLE001 - observability only, never raise
return None
def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix,
default_attr):
@@ -582,6 +609,22 @@ class AgentLauncher:
logger.warning(f"Agent run_id={run_id}: commit failed: {commit_result.stderr}")
else:
logger.info(f"Agent run_id={run_id}: no changes to commit")
# ORCH-061: on a self-deploy action stage (deploy-staging/deploy)
# an empty diff is EXPECTED (action, not a src edit). Emit an
# explicit observability line so an operator can tell this apart
# from a code-stage no-op. Does NOT affect advancement (decided by
# exit-code + gate verdict, never by a commit existing).
try:
_t = get_task_by_repo_branch(repo, branch)
_stage = _t["stage"] if _t else None
_note = action_stage_no_changes_note(_stage, repo)
if _note:
logger.info(f"Agent run_id={run_id}: {_note}")
except Exception as _e:
logger.debug(
f"Agent run_id={run_id}: action-stage no-changes note "
f"skipped: {_e}"
)
except Exception as e:
logger.error(f"Agent run_id={run_id}: post-run git failed: {e}")

View File

@@ -219,6 +219,22 @@ class Settings(BaseSettings):
image_freshness_enabled: bool = True
image_freshness_repos: str = ""
# ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite.
# The self-hosting deploy-staging stage looped because scripts/staging_check.py
# exited non-zero on ANY failed check, so two infra-only failures (sandbox bot
# accounts not members of the sandbox Plane project) produced staging_status:
# FAILED -> rollback deploy-staging -> development -> loop.
# True -> a run whose ONLY failures are allowlisted sandbox-infra checks
# (C9a/C9b) is waived to SUCCESS; ANY real pipeline check that fails
# still fails closed -> FAILED -> rollback (safety net intact, FR-4).
# False -> 1:1 pre-ORCH-061 strict behaviour: any FAIL -> FAILED -> rollback.
# Default True (mirrors merge_gate_enabled / image_freshness_enabled /
# self_deploy_enabled): the safety net holds regardless of the flag; the flag
# exists to instantly restore legacy strictness without a code redeploy. Lives
# in .env.staging (ORCH_ prefix) so it is reachable inside orchestrator-staging.
# Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED.
staging_infra_tolerance_enabled: bool = True
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background
# daemon thread reconciles the "source of truth (gate / Plane) != task stage"
# drift left behind by a dropped webhook (502 on rebuild, no Plane/Gitea
@@ -234,12 +250,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 = ""

View File

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

View File

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

View File

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

173
src/staging_verdict.py Normal file
View File

@@ -0,0 +1,173 @@
"""ORCH-061: pure staging-verdict logic (classification + tolerant verdict).
The self-hosting ``orchestrator`` looped on ``deploy-staging`` because
``scripts/staging_check.py`` summed ``all_ok = passed == total`` and exited
non-zero on ANY failed check — so two *infrastructure-only* failures (C9a branch
not found / C9b analyst-job not in queue, both caused by the SANDBOX bot accounts
not being members of the sandbox Plane project) produced ``staging_status:
FAILED`` → rollback ``deploy-staging → development`` → loop (ADR-001 §Context).
This module isolates the **pure verdict logic** so both outcomes are unit-testable
without a live staging stand or docker (TRZ §9):
* ``classify_check(label)`` — label → ``REAL`` | ``SANDBOX_INFRA`` (narrow,
allowlist-driven, fail-closed to ``REAL`` on anything unrecognised);
* ``compute_staging_verdict(items, infra_tolerant)`` — fold the per-check
pass/fail + category into a single ``StagingVerdict``.
It is a **leaf**: stdlib only, no I/O, no project imports — so it is safe to import
both from the orchestrator process and from ``scripts/staging_check.py`` (which
runs inside the ``orchestrator-staging`` container, pattern B6 / ORCH-048). Every
public function honours a **never-raise** contract: on any malformed input it
returns the *conservative* (fail-closed) result, never an exception.
Safety invariant (FR-4 / AC-3): a failed REAL check ALWAYS yields ``FAILED`` /
exit 1 regardless of ``infra_tolerant``. The waiver applies ONLY to the named
``SANDBOX_INFRA`` checks and ONLY when every REAL check (incl. C7/C8) is green —
so the blast-radius of the tolerance is exactly the two allowlisted checks.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Category constants ---------------------------------------------------------
REAL = "real" # a real pipeline check — fail-closed, always counts
SANDBOX_INFRA = "sandbox_infra" # known to depend on sandbox infra (waivable)
# Narrow allowlist of checks known to depend on sandbox infrastructure rather
# than the pipeline itself (ADR-001 §1). Matched by the check's leading label
# token, e.g. "C9a Branch appears in orchestrator-sandbox" -> token "C9a".
# Keep this set MINIMAL — every entry is a hole in the staging safety-net.
SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"})
def classify_check(label) -> str:
"""Classify a staging-check label as ``REAL`` or ``SANDBOX_INFRA``.
A label is ``SANDBOX_INFRA`` iff its leading whitespace-delimited token is one
of :data:`SANDBOX_INFRA_CHECKS` (exact match or prefix, e.g. ``"C9a"`` from
``"C9a Branch appears…"``). Everything else — and anything unrecognised /
malformed — is ``REAL`` (conservative / fail-closed: an unknown check counts
toward the safety net). Never raises.
"""
try:
text = str(label).strip()
if not text:
return REAL
token = text.split()[0]
for prefix in SANDBOX_INFRA_CHECKS:
if token == prefix or token.startswith(prefix):
return SANDBOX_INFRA
return REAL
except Exception:
return REAL
@dataclass
class StagingVerdict:
"""Outcome of folding the staging-check suite into a single verdict.
``status`` — ``"SUCCESS"`` | ``"FAILED"`` (mirrors the ``staging_status:``
frontmatter contract the deployer writes; unchanged).
``exit_code`` — ``0`` (advance) | ``1`` (rollback). Drives ``sys.exit`` in
``staging_check.py``.
``waived`` — labels of SANDBOX_INFRA checks that failed but were tolerated
(empty unless the waiver actually fired — observability, FR-7).
``summary`` — human-readable one-liner for logs.
"""
status: str
exit_code: int
waived: list = field(default_factory=list)
summary: str = ""
def _coerce_item(item) -> tuple[str, bool, str]:
"""Normalise an input row into ``(label, passed, category)``.
Accepts ``(label, passed)`` or ``(label, passed, category)``. A missing/None
category is resolved via :func:`classify_check`. Never raises — a malformed
row degrades to a failed REAL check (fail-closed) so it cannot silently pass.
"""
try:
label = str(item[0])
passed = bool(item[1])
category = item[2] if len(item) > 2 and item[2] else None
except Exception:
return ("<malformed>", False, REAL)
if category not in (REAL, SANDBOX_INFRA):
category = classify_check(label)
return (label, passed, category)
def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict:
"""Fold per-check results into a tolerant-but-fail-closed staging verdict.
``items`` — iterable of ``(label, passed: bool[, category: str])``.
Decision table (ADR-001 §1):
* any REAL check failed -> FAILED / exit 1 (safety net)
* only SANDBOX_INFRA failed & infra_tolerant -> SUCCESS / exit 0 (waived)
* only SANDBOX_INFRA failed & !infra_tolerant -> FAILED / exit 1 (legacy strict)
* nothing failed -> SUCCESS / exit 0
Never raises: on any internal error the verdict degrades to a conservative
``FAILED`` / exit 1 (never a false green) — AC-10.
"""
try:
real_failed: list[str] = []
infra_failed: list[str] = []
for raw in items:
label, passed, category = _coerce_item(raw)
if passed:
continue
if category == SANDBOX_INFRA:
infra_failed.append(label)
else:
real_failed.append(label)
if real_failed:
# Safety net (FR-4): a real pipeline regression always fails closed,
# regardless of tolerance. Infra failures (if any) are noted but the
# verdict is dominated by the real failure.
extra = f"; infra-fail {infra_failed}" if infra_failed else ""
return StagingVerdict(
status="FAILED",
exit_code=1,
waived=[],
summary=f"FAILED: real checks failed {real_failed}{extra}",
)
if infra_failed and infra_tolerant:
# Waiver fires ONLY here: every REAL check is green and the only
# failures are allowlisted sandbox-infra checks (FR-2).
return StagingVerdict(
status="SUCCESS",
exit_code=0,
waived=list(infra_failed),
summary=(
f"SUCCESS (infra-waived): {infra_failed} are known sandbox-infra "
"checks; all real checks green"
),
)
if infra_failed and not infra_tolerant:
# Legacy strict (kill-switch off): any failure fails closed (1:1 pre-061).
return StagingVerdict(
status="FAILED",
exit_code=1,
waived=[],
summary=f"FAILED (strict): {infra_failed} failed and tolerance disabled",
)
return StagingVerdict(
status="SUCCESS",
exit_code=0,
waived=[],
summary="SUCCESS: all checks green",
)
except Exception as e: # noqa: BLE001 - never-raise; fail closed on doubt
return StagingVerdict(
status="FAILED",
exit_code=1,
waived=[],
summary=f"FAILED (verdict error, fail-closed): {e}",
)

View File

@@ -142,3 +142,26 @@ def test_image_freshness_settings_env_override(monkeypatch):
s = Settings()
assert s.image_freshness_enabled is False
assert s.image_freshness_repos == "orchestrator,enduro-trails"
# ---------------------------------------------------------------------------
# ORCH-061 / TC-09: staging_infra_tolerance_enabled kill-switch (AC-7).
# ---------------------------------------------------------------------------
def test_staging_infra_tolerance_defaults_true(monkeypatch):
"""TC-09 / AC-7: the kill-switch defaults ON (safe default — the safety net
holds regardless; the flag exists to restore legacy strictness instantly)."""
monkeypatch.delenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", raising=False)
assert Settings().staging_infra_tolerance_enabled is True
def test_staging_infra_tolerance_env_override_false(monkeypatch):
"""TC-09 / AC-7: ORCH_STAGING_INFRA_TOLERANCE_ENABLED=false -> strict (1:1
pre-ORCH-061: infra-only FAIL again rolls back)."""
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "false")
assert Settings().staging_infra_tolerance_enabled is False
def test_staging_infra_tolerance_env_override_true(monkeypatch):
"""The field is read verbatim from its ORCH_* env var."""
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true")
assert Settings().staging_infra_tolerance_enabled is True

View File

@@ -0,0 +1,90 @@
"""ORCH-061 regression: the image must build from a git WORKTREE context.
The staging-image rebuild of ORCH-058 (``check_staging_image_fresh`` / the deploy
hook's ``--build-staging`` mode) uses the task **worktree** as the ``docker build``
context. A git worktree only contains git-TRACKED files, so any ``COPY`` of a
gitignored path makes ``docker build`` fail (rc=1) -> ``deploy-staging`` rolls back
to ``development`` (the exact loop ORCH-061 fixes).
The concrete regression: ``COPY data/ ./data/`` referenced ``data/`` which is
gitignored (runtime SQLite DB + backups) and therefore absent in every worktree.
At runtime ``data/`` always arrives via the compose bind mount
(``./data:/app/data`` / ``./data/staging:/app/data``), so baking it in was both
build-breaking and pointless.
These tests guard the invariant statically (no docker required): the Dockerfile
must not ``COPY`` a path that ``.gitignore`` excludes.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DOCKERFILE = REPO_ROOT / "Dockerfile"
GITIGNORE = REPO_ROOT / ".gitignore"
def _dockerfile_copy_sources() -> list[str]:
"""Source paths from every ``COPY <src...> <dst>`` line in the Dockerfile.
``--from`` (multi-stage / build-context) COPYs are skipped — they do not read
the worktree build context. The last token on a COPY line is the destination.
"""
sources: list[str] = []
for raw in DOCKERFILE.read_text().splitlines():
line = raw.strip()
if not line.upper().startswith("COPY "):
continue
if "--from" in line:
continue
tokens = line.split()[1:] # drop the COPY keyword
tokens = [t for t in tokens if not t.startswith("--")]
if len(tokens) >= 2:
sources.extend(tokens[:-1]) # all but the destination
return sources
def _gitignored_dirs() -> set[str]:
"""Top-level directory names excluded by ``.gitignore`` (e.g. ``data``)."""
dirs: set[str] = set()
for raw in GITIGNORE.read_text().splitlines():
entry = raw.strip()
if not entry or entry.startswith("#"):
continue
entry = entry.rstrip("/")
# only care about simple top-level dir patterns (no globs / nested paths)
if entry and "/" not in entry and "*" not in entry:
dirs.add(entry)
return dirs
def test_dockerfile_does_not_copy_gitignored_data():
"""``data/`` (gitignored runtime dir) must never be a Dockerfile COPY source."""
copy_sources = _dockerfile_copy_sources()
offending = [s for s in copy_sources if s.rstrip("/") == "data"]
assert not offending, (
"Dockerfile COPYs gitignored 'data/' -> build fails from a worktree "
f"context (rc=1). Offending COPY sources: {offending}. "
"Use `RUN mkdir -p /app/data` and rely on the compose bind mount instead."
)
def test_dockerfile_copies_only_git_tracked_sources():
"""No Dockerfile COPY source may be a gitignored top-level directory."""
gitignored = _gitignored_dirs()
copy_sources = [s.rstrip("/") for s in _dockerfile_copy_sources()]
leaking = sorted(set(copy_sources) & gitignored)
assert not leaking, (
"Dockerfile COPYs gitignored path(s) absent from git worktrees: "
f"{leaking}. The staging rebuild (ORCH-058) builds from the worktree and "
"will fail (rc=1)."
)
def test_data_dir_mount_target_is_created():
"""The image must create the /app/data mount target (no COPY dependency)."""
text = DOCKERFILE.read_text()
assert re.search(r"mkdir\s+-p\s+/app/data", text), (
"Dockerfile must `RUN mkdir -p /app/data` so the compose bind-mount "
"target exists without depending on a (gitignored) host data/ dir."
)

View File

@@ -278,3 +278,48 @@ class TestWatchdogGracefulKill:
assert signal.SIGKILL not in sent
assert recorded["called"] is False
# ---------------------------------------------------------------------------
# ORCH-061 / TC-06 + TC-07: "no changes to commit" on an action stage is EXPECTED,
# not under-delivery (FR-3 / AC-4). action_stage_no_changes_note is the PURE
# observability decision used by the post-run no-changes branch: it returns an
# explicit note for self-deploy action stages (deploy-staging/deploy) and None
# everywhere else. It NEVER signals a rollback — advancement is decided by the
# exit-code + gate verdict, never by a commit existing.
# ---------------------------------------------------------------------------
from src.agents.launcher import action_stage_no_changes_note # noqa: E402
class TestActionStageNoChangesNote:
def test_tc06_deploy_staging_self_deploy_returns_note(self):
"""TC-06 / AC-4: on deploy-staging for the self-hosting repo, an empty diff
yields an explicit "expected on action stage" note (no rollback signal)."""
note = action_stage_no_changes_note("deploy-staging", "orchestrator")
assert note is not None
assert "deploy-staging" in note
assert "expected on action stage" in note
def test_tc06_deploy_self_deploy_returns_note(self):
"""The `deploy` stage is equally an action stage for self-deploy."""
note = action_stage_no_changes_note("deploy", "orchestrator")
assert note is not None
assert "deploy: no code changes" in note
def test_tc07_development_stage_returns_none(self):
"""TC-07 / AC-4 regression-guard: on a CODE stage (development) the new
action-stage allowance does NOT apply — no note, behaviour unchanged."""
assert action_stage_no_changes_note("development", "orchestrator") is None
def test_tc06_non_self_repo_returns_none(self):
"""Conditionality (FR-5): the action-stage allowance is self-deploy only;
a non-self repo on deploy-staging gets no special note."""
assert action_stage_no_changes_note("deploy-staging", "enduro-trails") is None
def test_review_stage_returns_none(self):
"""Any non-action stage -> None (defensive: only deploy stages qualify)."""
assert action_stage_no_changes_note("review", "orchestrator") is None
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

View File

@@ -689,6 +689,27 @@ class TestCheckStagingStatus:
assert passed is True
assert "N/A" in reason
# ------------------------------------------------------------------
# ORCH-061 / TC-08: the conditional staging gate is unchanged for
# non-self-hosting repos AND independent of the new tolerance flag (FR-5/AC-6).
# ------------------------------------------------------------------
def test_tc08_non_self_na_independent_of_tolerance_flag(self, tmp_path, monkeypatch):
"""TC-08 / AC-6: for a non-self-hosting repo check_staging_status is the
byte-identical (True, "Staging gate N/A …") regardless of whether the
ORCH-061 infra-tolerance flag is on or off — the new behaviour never
activates off the self-hosting path."""
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
from src.qg.checks import check_staging_status
for flag in (True, False):
monkeypatch.setattr(
"src.config.settings.staging_infra_tolerance_enabled", flag,
raising=False,
)
passed, reason = check_staging_status("enduro-trails", "ET-035")
assert passed is True
assert reason == "Staging gate N/A for enduro-trails"
# ------------------------------------------------------------------
# is_self_hosting_repo helper
# ------------------------------------------------------------------

View File

@@ -51,3 +51,100 @@ def test_tc15_finalizer_log_roundtrips_through_parser():
ok_f, _ = _parse_deploy_status(build_deploy_log("ORCH-036", 2, "FAILED"))
assert ok_s is True
assert ok_f is False
# ---------------------------------------------------------------------------
# ORCH-061 / TC-04 + TC-05: infra-tolerant staging verdict (pure logic, AC-2/AC-3).
#
# compute_staging_verdict folds the staging-check suite into a single
# SUCCESS/FAILED verdict that is TOLERANT to known sandbox-infra failures
# (C9a/C9b) but stays fail-closed for any REAL pipeline check. These tests
# exercise the verdict directly — no live staging stand / docker (02-trz §9).
# ---------------------------------------------------------------------------
from src.staging_verdict import ( # noqa: E402
REAL,
SANDBOX_INFRA,
compute_staging_verdict,
)
def _rows(*specs):
"""Helper: build (label, passed, category) rows."""
return [(label, passed, cat) for label, passed, cat in specs]
def test_tc04_only_infra_failures_waived_to_success():
"""TC-04 / AC-2: every REAL check PASS, only known sandbox-infra checks
(C9a/C9b) FAIL, tolerance ON -> SUCCESS / exit 0 (no false rollback)."""
rows = _rows(
("C7 Create issue in Plane SANDBOX", True, REAL),
("C8 Trigger pipeline via /webhook/plane", True, REAL),
("C9a Branch appears in orchestrator-sandbox", False, SANDBOX_INFRA),
("C9b Analyst job enqueued in staging queue", False, SANDBOX_INFRA),
)
v = compute_staging_verdict(rows, infra_tolerant=True)
assert v.status == "SUCCESS"
assert v.exit_code == 0
# Both infra checks are surfaced as waived (observability, FR-7).
assert set(v.waived) == {
"C9a Branch appears in orchestrator-sandbox",
"C9b Analyst job enqueued in staging queue",
}
def test_tc05_any_real_failure_fails_closed():
"""TC-05 / AC-3: at least one REAL pipeline check FAILS (alongside the infra
ones) -> FAILED / exit 1 even with tolerance ON (safety net not weakened)."""
rows = _rows(
("C7 Create issue in Plane SANDBOX", False, REAL), # real regression
("C8 Trigger pipeline via /webhook/plane", True, REAL),
("C9a Branch appears in orchestrator-sandbox", False, SANDBOX_INFRA),
)
v = compute_staging_verdict(rows, infra_tolerant=True)
assert v.status == "FAILED"
assert v.exit_code == 1
assert v.waived == [] # nothing waived when a real check failed
def test_tc05_real_failure_fails_closed_even_alone():
"""A single REAL failure (no infra failures) is still FAILED (fail-closed)."""
rows = _rows(("C7 Create issue in Plane SANDBOX", False, REAL))
v = compute_staging_verdict(rows, infra_tolerant=True)
assert v.status == "FAILED"
assert v.exit_code == 1
def test_tc09_infra_failure_strict_mode_fails_closed():
"""TC-09 / AC-7: with tolerance OFF, an infra-only FAIL again -> FAILED
(1:1 pre-ORCH-061 strict behaviour)."""
rows = _rows(
("C7 Create issue in Plane SANDBOX", True, REAL),
("C9a Branch appears in orchestrator-sandbox", False, SANDBOX_INFRA),
)
v = compute_staging_verdict(rows, infra_tolerant=False)
assert v.status == "FAILED"
assert v.exit_code == 1
def test_all_green_is_success_regardless_of_tolerance():
rows = _rows(
("C7 Create issue in Plane SANDBOX", True, REAL),
("C9a Branch appears in orchestrator-sandbox", True, SANDBOX_INFRA),
)
for tol in (True, False):
v = compute_staging_verdict(rows, infra_tolerant=tol)
assert v.status == "SUCCESS"
assert v.exit_code == 0
assert v.waived == []
def test_tc12_compute_verdict_never_raises_on_garbage():
"""AC-10 never-raise: malformed rows degrade to a conservative FAILED, never
an exception."""
v = compute_staging_verdict([("only-one-element",)], infra_tolerant=True)
assert v.status == "FAILED"
assert v.exit_code == 1
# A completely broken iterable also fails closed without raising.
v2 = compute_staging_verdict(None, infra_tolerant=True)
assert v2.status == "FAILED"
assert v2.exit_code == 1

View File

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

View File

@@ -1132,3 +1132,158 @@ class TestDelegation:
assert args[0] == 5
assert args[1] == "analysis"
assert args[-1] is None
# ---------------------------------------------------------------------------
# ORCH-061: no deploy-staging loop on a healthy self-deploy; the ORCH-35 safety
# net (real staging FAIL -> rollback) stays intact; the new logic never raises
# into advance_stage; and "green with an infra allowance" is distinguishable from
# an honest green (observability).
# ---------------------------------------------------------------------------
class TestStagingInfraTolerance:
"""The verdict that produces ``staging_status:`` is computed in the suite
BEFORE the gate (ORCH-061 ADR-001 §4: check_staging_status is unchanged). At
the engine level we therefore assert the REACTION to the resulting verdict:
SUCCESS advances (no loop), a REAL FAILED rolls back (safety net)."""
def _patch_self_deploy_state(self, monkeypatch, tmp_path):
# Phase A writes restart-safe markers under repos_dir — keep them in tmp.
monkeypatch.setattr(stage_engine.self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.self_deploy.settings, "host_repos_dir", str(tmp_path))
def test_tc01_healthy_self_deploy_advances_no_rollback(self, monkeypatch, tmp_path):
"""TC-01 / AC-1: staging SUCCESS (infra-FAIL already waived in the suite)
+ green merge/freshness sub-gates -> deploy-staging advances to `deploy`
(Phase A approval-pending). NO rollback to development (loop is gone)."""
self._patch_self_deploy_state(monkeypatch, tmp_path)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
branch="feature/ORCH-061-x")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-061",
"feature/ORCH-061-x", finished_agent="deployer",
)
assert res.advanced is True
assert res.to_stage == "deploy"
assert _stage(task_id) == "deploy" # Phase A advanced the stage
assert res.rolled_back_to is None # NO loop back to development
assert res.note == "self-deploy-approval-pending"
def test_tc02_real_staging_failed_rolls_back(self, monkeypatch, tmp_path):
"""TC-02 / AC-3: a REAL staging failure (verdict FAILED) still rolls
deploy-staging back to development + set_issue_blocked + alert — the
ORCH-35 safety net is NOT weakened by the infra tolerance (FR-4)."""
self._patch_self_deploy_state(monkeypatch, tmp_path)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _fail("Staging status: FAILED")},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
branch="feature/ORCH-061-x")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-061",
"feature/ORCH-061-x", finished_agent="deployer",
)
assert res.advanced is False
assert res.rolled_back_to == "development"
assert _stage(task_id) == "development"
assert res.alerted is True
assert stage_engine.set_issue_blocked.called
assert stage_engine.send_telegram.called
def test_tc12_gate_exception_never_crashes_advance(self, monkeypatch, tmp_path):
"""TC-12 / AC-10 never-raise: if the staging gate raises (io/parse/docker
hiccup), advance_stage catches it deterministically — no exception escapes,
the task does NOT advance and is NOT falsely rolled back to development."""
self._patch_self_deploy_state(monkeypatch, tmp_path)
def _boom(*a, **k):
raise RuntimeError("staging gate blew up")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_staging_status": _boom},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
branch="feature/ORCH-061-x")
# Must NOT raise.
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-061",
"feature/ORCH-061-x", finished_agent="deployer",
)
assert res.advanced is False
assert res.rolled_back_to is None # exception != gate FAILED
assert _stage(task_id) == "deploy-staging" # stays put, no loop
assert res.note and "error" in res.note
def test_tc13_end_to_end_self_deploy_no_single_rollback(self, monkeypatch, tmp_path):
"""TC-13 / AC-1+AC-4 integration: a healthy self-deploy goes
deploy-staging -> deploy (Phase A) -> (approve/finalize SUCCESS) -> done
WITHOUT a single rollback to development in the transition log."""
self._patch_self_deploy_state(monkeypatch, tmp_path)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass,
"check_deploy_status": _pass},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
branch="feature/ORCH-061-x")
seen_stages = []
# 1) deploy-staging -> deploy (Phase A approval-pending).
r1 = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-061",
"feature/ORCH-061-x", finished_agent="deployer",
)
seen_stages.append(_stage(task_id))
assert r1.advanced is True
assert _stage(task_id) == "deploy"
# 2) finalizer (Phase C): deploy verdict SUCCESS -> done.
r2 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-061",
"feature/ORCH-061-x", finished_agent="deployer",
)
seen_stages.append(_stage(task_id))
assert r2.advanced is True
assert _stage(task_id) == "done"
# Not a single rollback to development anywhere in the path.
assert "development" not in seen_stages
assert r1.rolled_back_to is None and r2.rolled_back_to is None
def test_tc14_waived_green_distinguishable_from_honest_green(self):
"""TC-14 / AC-11 observability: the staging verdict makes "green with an
infra allowance" distinguishable from an honest green — the waived list is
populated and the summary says so, vs an empty waived list + plain summary
for an all-green run."""
from src.staging_verdict import REAL, SANDBOX_INFRA, compute_staging_verdict
waived = compute_staging_verdict(
[("C7", True, REAL),
("C9a", False, SANDBOX_INFRA)],
infra_tolerant=True,
)
honest = compute_staging_verdict(
[("C7", True, REAL),
("C9a", True, SANDBOX_INFRA)],
infra_tolerant=True,
)
# Both advance...
assert waived.status == honest.status == "SUCCESS"
# ...but only the waived one carries the explicit allowance marker.
assert waived.waived == ["C9a"]
assert "infra-waived" in waived.summary.lower()
assert honest.waived == []
assert "infra-waived" not in honest.summary.lower()

View File

@@ -149,3 +149,70 @@ def test_run_b6_records_pass_for_clean_registry(monkeypatch):
_label, passed, detail = results._items[0]
assert passed is True
assert "sandbox=YES" in detail
# ---------------------------------------------------------------------------
# ORCH-061 / TC-03: the suite classifies checks as REAL vs SANDBOX_INFRA so the
# verdict (and exit-code) can tolerate KNOWN sandbox-infra FAILs (C9a/C9b) while
# staying fail-closed for real pipeline checks. Tested without a live stand.
# ---------------------------------------------------------------------------
from src.staging_verdict import REAL, SANDBOX_INFRA # noqa: E402
def test_tc03_classify_infra_checks():
"""C9a/C9b classify as SANDBOX_INFRA; pipeline checks (A/B/C7/C8) as REAL."""
assert sc._classify("C9a Branch appears in orchestrator-sandbox") == SANDBOX_INFRA
assert sc._classify("C9b Analyst job enqueued in staging queue") == SANDBOX_INFRA
assert sc._classify("C7 Create issue in Plane SANDBOX") == REAL
assert sc._classify("C8 Trigger pipeline via /webhook/plane") == REAL
assert sc._classify("A1 GET /health") == REAL
assert sc._classify("B6 Registry: sandbox present") == REAL
def test_tc03_results_records_categories_and_keeps_tuple_shape():
"""Results.add auto-classifies each check; categorized_items() exposes the
category WITHOUT changing the public 3-tuple shape of _items (ORCH-048 b6
tests still unpack (label, passed, detail))."""
results = sc.Results()
results.add("C7 Create issue in Plane SANDBOX", True)
results.add("C9a Branch appears in orchestrator-sandbox", False)
# Public _items shape unchanged (regression guard for ORCH-048 tests).
for item in results._items:
assert len(item) == 3
cats = {label: cat for label, _passed, cat in results.categorized_items()}
assert cats["C7 Create issue in Plane SANDBOX"] == REAL
assert cats["C9a Branch appears in orchestrator-sandbox"] == SANDBOX_INFRA
def test_tc03_explicit_category_overrides_autoclassify():
"""An explicit category arg is honoured (caller can force REAL)."""
results = sc.Results()
results.add("C9a Branch appears in orchestrator-sandbox", False, category=REAL)
label, _passed, cat = results.categorized_items()[0]
assert cat == REAL
def test_tc03_suite_verdict_waives_infra_only_failure():
"""End-to-end through the suite helpers: a run whose only failures are C9a/C9b
-> exit 0 (waived) under tolerance; the waiver is surfaced for observability."""
results = sc.Results()
results.add("C7 Create issue in Plane SANDBOX", True)
results.add("C8 Trigger pipeline via /webhook/plane", True)
results.add("C9a Branch appears in orchestrator-sandbox", False)
results.add("C9b Analyst job enqueued in staging queue", False)
verdict = sc._verdict(results.categorized_items(), infra_tolerant=True)
assert verdict.status == "SUCCESS"
assert verdict.exit_code == 0
assert len(verdict.waived) == 2
# Strict mode (kill-switch off) re-fails the same run.
strict = sc._verdict(results.categorized_items(), infra_tolerant=False)
assert strict.exit_code == 1
def test_tc03_resolve_tolerance_strict_flag_forces_off():
"""--strict forces tolerance OFF regardless of the config default."""
assert sc._resolve_tolerance(cli_strict=True) is False