Compare commits

..

47 Commits

Author SHA1 Message Date
c0bcb544cf tester(ET): auto-commit from tester run_id=201
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 15s
2026-06-06 21:07:35 +00:00
2be39b398b reviewer(ET): auto-commit from reviewer run_id=199 2026-06-06 21:07:35 +00:00
d79defeadd fix(deploy): clear stale self-deploy markers on rollback; document env
Re-deploy after a FAILED prod deploy wedged the task on `deploy`: the
sentinel markers (approve-requested/initiated/result) are keyed by the
stable work_item_id, so after the БАГ-8 rollback (deploy -> development)
and a developer fix, Phase B's idempotency-guard saw a STALE `initiated`
and became a no-op — the detached hook never re-launched and the
finalizer was never enqueued. Add self_deploy.clear_state (never-raise,
idempotent) and call it on the check_deploy_status FAILED rollback and at
the start of Phase A, so every fresh prod-deploy pass starts clean.

Also document the new ORCH_SELF_DEPLOY_* / ORCH_DEPLOY_* descriptors in
the canonical .env.example (CLAUDE.md rule #8, ТЗ §2.6), modelled on the
ORCH-043 merge-gate block (placeholders only, secrets not committed).

Contracts untouched: STAGE_TRANSITIONS, QG_CHECKS, _parse_deploy_status,
БАГ-8, merge-gate.

Refs: ORCH-036
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:35 +00:00
9f43e6a0ae reviewer(ET): auto-commit from reviewer run_id=195 2026-06-06 21:07:35 +00:00
10f2a39a58 feat(deploy): build-once SOURCE_IMAGE retag in hook + deploy-stage docs
Add the optional, backward-compatible SOURCE_IMAGE branch to
orchestrator-deploy-hook.sh: when set, retag the staging-validated image
onto TARGET_IMAGE (docker tag) before `up -d --no-build` instead of
rebuilding — guarantees prod runs the exact artefact that passed staging
(AC-7 / TC-14). Unset -> prior behaviour; exit-code contract (0/1/2) and
health-loop untouched.

Update golden-source docs (AC-13): rewrite deployer.md `deploy` stage from
"paper SUCCESS" to the executable self-deploy (Phase A/B/C, no self-restart
from inside the container) and add the ORCH-036 CHANGELOG entry.

Refs: ORCH-036

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:35 +00:00
63187ff102 developer(ET): auto-commit from developer run_id=192 2026-06-06 21:07:35 +00:00
5c5525548d architect(ET): auto-commit from architect run_id=190 2026-06-06 21:07:35 +00:00
0d0cd6e281 analyst(ET): auto-commit from analyst run_id=189 2026-06-06 21:07:35 +00:00
480b203a9d docs: init ORCH-036 business request 2026-06-06 21:07:35 +00:00
7705552f08 docs(ORCH-036): staging gate log — staging_status SUCCESS (10/10 PASS)
Re-run of deploy-staging gate (merge-gate defer cycle). Canonical
staging_check.py (mode=stub) ran inside orchestrator-staging (8501);
all 10 checks passed (exit 0). No prod (8500) container touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:20 +00:00
d43603b224 docs(ORCH-053): deploy gate log — deploy_status SUCCESS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:04:04 +00:00
682ae09316 docs(ORCH-036): staging gate SUCCESS log (10/10 checks PASS)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:58:12 +00:00
434bd6243d docs(ORCH-053): staging gate SUCCESS log
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:55:10 +00:00
c21a279565 Merge pull request 'feat(merge-gate): auto-rebase onto current main + re-test + serialise merges (ORCH-043)' (#54) from feature/ORCH-043-merge-gate-auto-rebase-re-test into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 21:24:33 +03:00
d9afb3a10d docs(ORCH-043): deploy gate log — deploy_status SUCCESS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:45:13 +00:00
8447853db8 deployer(ET): auto-commit from deployer run_id=187
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
2026-06-06 17:41:23 +00:00
5dc5893a49 docs(ORCH-043): staging gate log — staging_status SUCCESS
Live staging-stand suite (scripts/staging_check.py, stub mode) ran inside
orchestrator-staging: 10/10 checks PASS, exit code 0. Merge-gate edge
(deploy-staging → deploy) cleared for ORCH-043.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:41:06 +00:00
581a8b595a tester(ET): auto-commit from tester run_id=186
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 15s
2026-06-06 17:38:38 +00:00
ba51aa17bc reviewer(ET): auto-commit from reviewer run_id=185
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 18s
2026-06-06 17:37:05 +00:00
00d69d9e27 feat(merge-gate): auto-rebase onto current main + re-test + serialise merges
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 17s
Deterministic (no-LLM) sub-gate on the deploy-staging -> deploy edge that
catches a feature branch up to the CURRENT origin/main, re-tests the combined
tree, and serialises merges with a per-repo file lease — so two green parallel
branches can no longer break main (self-hosting safety for the orchestrator repo).

- src/merge_gate.py: branch_is_behind_main, auto_rebase_onto_main (push
  --force-with-lease ONLY the task branch, NEVER main), retest_branch, and a
  file merge-lease (atomic O_CREAT|O_EXCL, holder-aware release, stale reclaim).
  Strict never-raise contract; all git ops in the per-branch worktree.
- src/qg/checks.py: check_branch_mergeable composes the primitives under the
  lease; registered in QG_CHECKS. Conditional rollout (merge_gate_enabled /
  merge_gate_repos, default self-hosting only).
- src/stage_engine.py: sub-gate hook on deploy-staging (not a new stage). PASS ->
  advance; "merge-lock busy" -> DEFER (re-queue with available_at, anti-deadlock
  at max_concurrency=1, capped); conflict/red re-test -> rollback to development
  + developer retry (capped by MAX_DEVELOPER_RETRIES). Lease released on
  deploy->done / rollback / PR-merged webhook.
- src/db.py: enqueue_job(available_at_delay_s=...) for the defer (no schema change).
- src/webhooks/gitea.py: holder-aware lease release on PR-merged.
- src/config.py + .env.example: ORCH_MERGE_* settings.

Docs: README + adr-0006 (architect) already cover the design; CHANGELOG updated.
Tests: test_merge_gate.py, test_qg_merge_gate.py, test_merge_gate_race.py,
test_stage_engine.py::TestMergeGate, test_config.py, QG-registry snapshot.
Full suite: 535 passed.

Refs: ORCH-043

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:32:50 +00:00
ad1589084b architect(ET): auto-commit from architect run_id=183
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 17:16:00 +00:00
77e7205ce8 analyst(ET): auto-commit from analyst run_id=182
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 16:39:20 +00:00
445807dd90 docs: init ORCH-043 business request
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 19:31:37 +03:00
39cb5dde70 Merge pull request 'fix(infra): ORCH-040 run containers as host uid 1000:1000 (not root)' (#53) from feature/ORCH-040-root-git into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 19:26:35 +03:00
7b748b7ac5 docs(ORCH-040): deploy gate log — deploy_status SUCCESS
Self-hosting deploy verdict: artifact validated (staging gate green, compose
user=1000:1000 with МИНА 1 group_add intact). Prod cut-over handed to Owner
(P-1…P-4 + deploy hook) — in-task prod restart not performed by design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:11:08 +00:00
bcf5256731 deployer(ET): auto-commit from deployer run_id=180
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 14s
2026-06-06 15:09:01 +00:00
80275a3336 docs(ORCH-040): staging gate log — staging_status SUCCESS (10/10)
Staging check suite passed 10/10 (exit 0), run canonically inside
orchestrator-staging via the Docker Engine API (docker exec equivalent).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:08:50 +00:00
59e47ba067 tester(ET): auto-commit from tester run_id=179
All checks were successful
CI / test (push) Successful in 14s
CI / test (pull_request) Successful in 14s
2026-06-06 15:07:07 +00:00
be64761654 reviewer(ET): auto-commit from reviewer run_id=178
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s
2026-06-06 15:05:26 +00:00
f81715bd39 fix(infra): run orchestrator containers as host uid 1000:1000 (not root)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
Both compose services (orchestrator, orchestrator-staging) now declare
user: "1000:1000" so pipeline artifacts (git worktree, docs/work-items
commits) are created as slin:slin on the host — git pull/reset under slin
no longer fail with permission errors. docker.sock access preserved via
group_add: ["999"]. SSH mount target aligned with the launcher-forced
HOME=/home/slin (/root/.ssh -> /home/slin/.ssh). launcher.py and Dockerfile
unchanged. INFRA.md and CHANGELOG.md updated; host-prerequisites (P-1..P-4)
documented.

Refs: ORCH-040

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:02:33 +00:00
fe5eb38af2 architect(ET): auto-commit from architect run_id=176
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 14:59:07 +00:00
5436c4110e analyst(ET): auto-commit from analyst run_id=175
All checks were successful
CI / test (push) Successful in 13s
2026-06-06 14:55:35 +00:00
8e91c8c23c analyst(ET): auto-commit from analyst run_id=174
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 14:49:21 +00:00
83e26279bf docs: init ORCH-040 business request
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 17:46:34 +03:00
3441f01650 Merge pull request 'feat(notifications): ORCH-042 Telegram tracker bump mode + russification' (#52) from feature/ORCH-042-telegram-live-tracker-bump into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 13:48:55 +03:00
18378c2713 docs(ORCH-042): add deploy log (deploy_status: SUCCESS)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 10:22:01 +00:00
753eea37fc deployer(ET): auto-commit from deployer run_id=172
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 12s
2026-06-06 10:20:02 +00:00
efbd8b7b8f docs(ORCH-042): add staging gate log (staging_status: SUCCESS)
Staging check suite ran inside orchestrator-staging (port 8501): 10/10 PASS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 10:19:50 +00:00
6ef28efccd tester(ET): auto-commit from tester run_id=171
All checks were successful
CI / test (push) Successful in 14s
CI / test (pull_request) Successful in 12s
2026-06-06 10:17:45 +00:00
52cfe51bd8 reviewer(ET): auto-commit from reviewer run_id=170
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-06 10:16:31 +00:00
05c17135c1 feat(notifications): add bump mode + russify Telegram live-tracker
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s
ORCH-042: new ORCH_TRACKER_MODE (Settings.tracker_mode, default edit) selects
the live-tracker card behaviour. bump mode re-creates the card at the bottom of
the chat on every update (delete_telegram + send silently + repoint message_id),
keeping the "one card per task" invariant: <=1 new message per call, repoint
only on successful send, delete result never gates the send. New never-raising
delete_telegram helper. Anything != "bump" resolves to edit (zero regression).

Also russify/cosmetic-fix the card text (both modes): "Подтверждение BRD" label,
 after approve-gate, Russian stage labels, "📦 Внедрено". Docs updated in the
same PR (CHANGELOG, internals.md, .env.example).

Refs: ORCH-042

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 10:13:49 +00:00
0ac50b8c73 architect(ET): auto-commit from architect run_id=168
All checks were successful
CI / test (push) Successful in 12s
2026-06-06 10:05:26 +00:00
66100855f6 analyst(ET): auto-commit from analyst run_id=167
All checks were successful
CI / test (push) Successful in 15s
2026-06-06 09:49:58 +00:00
3f23897327 docs: init ORCH-042 business request
All checks were successful
CI / test (push) Successful in 13s
2026-06-06 12:27:13 +03:00
ed10f28879 docs(ORCH-044): add deploy log (deploy_status: SUCCESS)
Some checks failed
CI / test (push) Has been cancelled
Artifact-only production deploy verdict for ORCH-044. All gates green
(review APPROVED, tests PASS, staging SUCCESS 10/10). src/ runtime
changed → real rebuild+restart of prod orchestrator (8500) delegated to
Owner-run deploy hook (ORCH-36); prod container not touched by agent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:45:08 +00:00
45480966c1 Merge pull request '#51' staging-log/ORCH-044 into main 2026-06-06 11:42:58 +03:00
a662eeb2a1 docs(ORCH-044): staging gate log — SUCCESS (10/10, B6 registry isolation PASS)
All checks were successful
CI / test (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:42:47 +00:00
108 changed files with 8276 additions and 1861 deletions

View File

@@ -12,3 +12,62 @@ ORCH_GITEA_WEBHOOK_SECRET=
ORCH_CLAUDE_BIN=/usr/bin/claude
ORCH_REPOS_DIR=/home/slin/repos
ORCH_DB_PATH=/app/data/orchestrator.db
# ORCH-042: live-tracker mode. edit (DEFAULT) -> the task card is edited in place
# (editMessageText). bump -> on every update the old card is deleted and a fresh
# one is sent silently to the BOTTOM of the chat (deleteMessage + sendMessage +
# repoint). One card per task in both modes. Any value other than "bump" -> edit.
ORCH_TRACKER_MODE=edit
# ORCH-043: merge-gate (auto-rebase onto current origin/main + re-test + merge-lock)
# on the deploy-staging -> deploy edge. Deterministic sub-gate (no LLM) that catches
# the branch up to the CURRENT origin/main, re-tests it, and serialises merges so two
# green parallel branches can't break main.
# ENABLED -> global kill-switch (false -> whole gate is a no-op pass).
# REPOS -> CSV of repos where the gate is REAL; empty -> only the self-hosting
# repo (orchestrator); other repos -> conditional no-op (mirrors ORCH-35).
# RETEST_TIMEOUT_S -> wall-clock budget for the post-rebase re-test.
# RETEST_TARGET -> pytest target for the re-test.
# LOCK_TIMEOUT_S -> max merge-lease age before a stale lease is reclaimed.
# DEFER_DELAY_S -> delay before re-running the gate when the lock is busy.
# DEFER_MAX_ATTEMPTS -> defer retries before escalation (avoids livelock).
ORCH_MERGE_GATE_ENABLED=true
ORCH_MERGE_GATE_REPOS=
ORCH_MERGE_RETEST_TIMEOUT_S=600
ORCH_MERGE_RETEST_TARGET=tests/
ORCH_MERGE_LOCK_TIMEOUT_S=300
ORCH_MERGE_DEFER_DELAY_S=60
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
# deterministic phases (A: request approve, B: human Approved -> detached deploy,
# C: finalizer maps hook exit-code -> deploy_status). Non-self repos: unchanged
# synchronous ssh deploy. SECRETS / host paths live ONLY on the host — do NOT commit.
# SELF_DEPLOY_ENABLED -> global kill-switch (false -> legacy synchronous deploy for all).
# SELF_DEPLOY_REPOS -> CSV of repos where Phase A/B/C is REAL; empty -> only the
# self-hosting repo (orchestrator); others -> no-op (mirrors ORCH-35).
# DEPLOY_REQUIRE_MANUAL_APPROVE -> require a human Plane "Approved" before the prod
# deploy (true on rollout; full auto is ORCH-54).
# DEPLOY_FINALIZE_DELAY_S -> delay before the first/each finalize poll (>= hook+health).
# DEPLOY_FINALIZE_MAX_ATTEMPTS -> bounded finalize-defer budget (anti-livelock).
# DEPLOY_SSH_USER / DEPLOY_SSH_HOST -> ssh target for the host hook (DEPLOY_SSH_HOST
# empty -> detached deploy will NOT launch; set on the host).
# DEPLOY_HOOK_SCRIPT -> path to the hook ON THE HOST (relative to the repo).
# DEPLOY_HOST_REPO_PATH -> orchestrator clone path on the host.
# DEPLOY_PROD_SOURCE_IMAGE -> staging-validated image, retagged build-once (no rebuild).
# DEPLOY_PROD_TARGET_SERVICE / _PORT / _IMAGE / _COMPOSE_PROFILE -> prod compose profile.
# DEPLOY_PROD_PREV_IMAGE_FILE -> prod prev-image snapshot (separate from staging's).
ORCH_SELF_DEPLOY_ENABLED=true
ORCH_SELF_DEPLOY_REPOS=
ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE=true
ORCH_DEPLOY_FINALIZE_DELAY_S=90
ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS=10
ORCH_DEPLOY_SSH_USER=slin
ORCH_DEPLOY_SSH_HOST=
ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh
ORCH_DEPLOY_HOST_REPO_PATH=/home/slin/repos/orchestrator
ORCH_DEPLOY_PROD_SOURCE_IMAGE=orchestrator-orchestrator-staging
ORCH_DEPLOY_PROD_TARGET_SERVICE=orchestrator
ORCH_DEPLOY_PROD_TARGET_PORT=8500
ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator
ORCH_DEPLOY_PROD_COMPOSE_PROFILE=
ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod

View File

@@ -73,13 +73,39 @@ On stage `deploy-staging` your job is to run the staging test suite and write a
---
## Stage: `deploy` (Production Deploy — ORCH-36, future)
On stage `deploy` your job is to perform (or simulate) the production deployment and write a machine-readable verdict to `docs/work-items/<work_item_id>/14-deploy-log.md` with frontmatter field `deploy_status: SUCCESS|FAILED`.
## Stage: `deploy` (Production Deploy — ORCH-36, executable self-deploy)
This stage is only reached if the staging gate (`deploy-staging`) passed with `staging_status: SUCCESS`.
The verdict contract is unchanged: `docs/work-items/<work_item_id>/14-deploy-log.md` with
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
⚠️ **CRITICAL**: Do NOT trigger real production deploys unless explicitly instructed. Real docker/SSH deploys are handled by `scripts/orchestrator-deploy-hook.sh` (ORCH-36).
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
`src/stage_engine.py` + `src/self_deploy.py`, NOT by you, and NOT by a "paper" `SUCCESS`:
- **Phase A** (entering `deploy`): the pipeline does NOT launch you. It sets the issue to an
approval-pending state and asks a human to flip the Plane status to **Approved**.
- **Phase B** (human Approved): the code launches a **detached host process**
(`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`) that retags the staging-validated
image onto the prod tag (build-once, `SOURCE_IMAGE`), restarts prod (8500) and health-checks.
The orchestrator NEVER restarts its own 8500 container from inside — that would kill the
worker mid-call.
- **Phase C** (finalizer): a deterministic finalizer-job in the NEW container reads the hook
exit-code, maps `0 → SUCCESS`, `1|2|other → FAILED`, writes `14-deploy-log.md` and drives the
existing contracts (`SUCCESS → done`, `FAILED → rollback to development`).
⚠️ **CRITICAL for self-hosting**: NEVER run `docker compose up -d orchestrator`, `--build`, or any
restart of 8500 from inside the agent. `deploy_status: SUCCESS` must reflect a REAL host health-ok,
never an LLM declaration. If you are ever launched on `deploy` for `orchestrator`, do nothing that
restarts prod — the host hook owns the restart.
### Non-self repos (e.g. `enduro-trails`) — unchanged synchronous ssh deploy
For non-self repos behaviour is unchanged: perform the production deployment (ssh to the project
host) and write the machine-readable verdict (`deploy_status: SUCCESS|FAILED`). Real docker/SSH
deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults are STAGING-safe).
---

View File

@@ -5,7 +5,9 @@
## [Unreleased]
### Added
- **Надёжность запуска агента: preflight ловит авторизацию + пустой результат = провал** (ORCH-044): закрыты две системные дыры, из-за которых разлогиненный/«быстро умерший» агент тихо вешал общую очередь всех проектов (инцидент ORCH-17). **P1 — preflight ловит auth (token-free, без сети/prompt-ping, BR-1):** после успешного `claude --version` (который отвечает даже когда claude разлогинен — версия локальна) `src/preflight.py` читает `<AGENT_HOME>/.claude/.credentials.json` и валидирует OAuth-токен — нет файла / битый JSON / нет `claudeAiOauth.accessToken` ⇒ FAIL; `claudeAiOauth.expiresAt` (epoch ms) `<= now + ORCH_AUTH_EXPIRY_SKEW_SECONDS` ⇒ протух ⇒ FAIL; нет `expiresAt` ⇒ OK (не плодим ложных срабатываний). Путь к credentials резолвится от `AgentLauncher.AGENT_HOME` (`/home/slin`, HOME под которым launcher реально спавнит claude), а не от HOME процесса орка (новый `_agent_home()`, зеркально `_claude_bin()`). Результат кешируется тем же `ORCH_PREFLIGHT_CACHE_TTL`. При `auth=fail` job не клеймится (`_drain_once` уже корректен при `ok=False`), reason виден в `/queue`. Защитная сетка постфактум: `_handle_auth_marker` детектит маркер разлогина в run-логе (`is_auth_failure_text`) и сбрасывает preflight-кеш, чтобы следующий тик переоценил auth (auth-провал НЕ transient, breaker не крутится). Новые настройки: `ORCH_PREFLIGHT_CHECK_AUTH` (тумблер, default true), `ORCH_CLAUDE_CREDENTIALS_PATH` (явный путь), `ORCH_AUTH_EXPIRY_SKEW_SECONDS`. **P3 — пустой лог / нет result-JSON ⇒ провал:** `exit_code==0` больше не считается успехом сам по себе`_monitor_agent` валидирует результат (`_validate_result`: лог непустой + есть trailing result-JSON по контракту `usage._extract_last_json_object`); `success = exit 0 AND result_ok`. Только при `success` постится «успешный» status-коммент и вызывается `_try_advance_stage`; при `exit 0 & not result_ok` — Telegram-алерт, стадия НЕ двигается, `_finalize_job(result_ok=False)` маршрутизирует job в провал (`empty run log / no result JSON`: по умолчанию permanent → requeue/`failed`+алерт; transient-маркер в логе → transient-путь). Реальный `exit_code` пишется в `agent_runs` без искажения — решение done/fail несёт отдельный флаг `result_ok` (не подменённый код выхода). Итог: `exit 0` всегда завершается терминально/ретраябельно (`done`|`failed`|`queued`) — путь «быстрая смерть с exit 0 → вечный running» закрыт. ⛔ Scope: `--effort` (P2) исключён владельцем и вынесен в ORCH-50 — не трогался. ADR `docs/work-items/ORCH-044/06-adr/ADR-001-preflight-auth-and-empty-result-failure.md`. Тесты: `tests/test_preflight_auth.py`, `tests/test_empty_log_failure.py`.
- **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid``scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<work_item_id>/`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`.
- **Merge-gate: авто-rebase на текущий `origin/main` + повторный прогон тестов + сериализация мержей** (ORCH-043): детерминированный (без LLM) суб-гейт на ребре `deploy-staging → deploy`, выполняемый ПЕРЕД мержем PR деплоером. Закрывает класс гонок «две зелёные ветки в одном репо ломают `main`»: пайплайн валидирует ветку против того `main`, от которого она ответвилась, а не против `main` в момент мержа — между «ветка зелёная» и «ветка смержена» параллельная задача может сдвинуть `main` (семантический конфликт: git мержит без текстового конфликта, но совмещённый `main` красный). Для self-hosting репозитория `orchestrator` это означало бы красный `main` инструмента, обслуживающего ВСЕ проекты. Новый модуль `src/merge_gate.py` (контракт «never raise», все git-операции — в per-branch worktree, ORCH-2/S-4): `branch_is_behind_main` (`git merge-base --is-ancestor origin/main HEAD`), `auto_rebase_onto_main` (rebase + `git push --force-with-lease` ТОЛЬКО ветки задачи — `main` НИКОГДА не пушится; текстовый конфликт → `rebase --abort` + чистый worktree), `retest_branch` (`python -m pytest <target>` в догнанном worktree, бюджет `merge_retest_timeout_s`), файловый merge-lease (`acquire_merge_lease`/`release_merge_lease`, атомарный `O_CREAT|O_EXCL`, holder-aware release, реклейм протухшего/битого лиза — без изменения схемы БД). Новый quality-gate `check_branch_mergeable` (`src/qg/checks.py`, зарегистрирован в `QG_CHECKS`) композирует примитивы под лизом: kill-switch/вне-области → no-op pass; lock занят → `(False, "merge-lock busy")` (сигнал DEFER, не код-фолт); ветка свежая → pass (лиз ДЕРЖИТСЯ до мержа); отстала → rebase → конфликт = fail+release, чисто → retest → зелёный = pass (лиз держится) / красный|timeout = fail+release. Интеграция в `src/stage_engine.py` (суб-гейт на `deploy-staging`, БЕЗ новой стадии в `STAGE_TRANSITIONS`): pass → advance на `deploy`; «merge-lock busy» → DEFER (повторная постановка деплоера на `deploy-staging` с задержкой `available_at`, анти-дедлок при `max_concurrency=1`, restart-safe счётчик по `task_content`, лимит `merge_defer_max_attempts` → block+Telegram); конфликт/красный retest → ROLLBACK на `development` + ретрай developer-а (кап `MAX_DEVELOPER_RETRIES`, без бесконечного баунса). Лиз освобождается на `deploy→done`, на rollback и по webhook смерженного PR (`src/webhooks/gitea.py`). Новый параметр `enqueue_job(..., available_at_delay_s=...)` (`src/db.py`) — отложенная постановка без изменения схемы. Условность раскатки (зеркало ORCH-35): `merge_gate_repos` (CSV) или по умолчанию только self-hosting `orchestrator`; глобальный kill-switch `merge_gate_enabled`. Новые настройки `ORCH_MERGE_GATE_ENABLED` (true), `ORCH_MERGE_GATE_REPOS` (""), `ORCH_MERGE_RETEST_TIMEOUT_S` (600), `ORCH_MERGE_RETEST_TARGET` (tests/), `ORCH_MERGE_LOCK_TIMEOUT_S` (300), `ORCH_MERGE_DEFER_DELAY_S` (60), `ORCH_MERGE_DEFER_MAX_ATTEMPTS` (5). ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`, глобальный `docs/architecture/adr/adr-0006-merge-gate.md`. Тесты: `tests/test_merge_gate.py`, `tests/test_qg_merge_gate.py`, `tests/test_merge_gate_race.py`, `tests/test_stage_engine.py::TestMergeGate`, `tests/test_config.py`.
- **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)``send_telegram(text, disable_notification=True)``set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`.
- **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты``## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`.
- **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after <T>s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.
- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-`<a>`-ссылки — на `docs/work-items/<WI>/01-brd.md` (Gitea branch-view: `gitea_public_url``gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`.
@@ -20,10 +22,13 @@
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
### Changed
- **Русификация и косметика карточки live-трекера Telegram** (ORCH-042, оба режима): метка `Подтверждение BRD` вместо «Ревью БРД» (`_BRD_LABEL`); после прохождения approve-gate строка подтверждения BRD начинается с ✅ вместо ⏸️ (ветка ожидания человека сохраняет ⏸️/⏳); русские display-labels стадий в `_TRACKER_STAGES` (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`) — применяются и в «✅ …», и в «🔄 … идёт»; финальная строка готовой задачи `📦 Внедрено` вместо `deployed` (`_done_link`). Меняются только отображаемые строки — ключи стадий и имена агентов не трогаются. Существующие ассерты `tests/test_telegram_tracker.py` обновлены под русские метки.
- **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `<li><a>…</a></li>` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись.
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
- **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`.
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md``result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.

View File

@@ -3,6 +3,11 @@ services:
build: .
container_name: orchestrator
restart: unless-stopped
# ORCH-040: бежим под uid:gid хоста (slin=1000:1000), а не root, чтобы
# артефакты конвейера (worktree + docs) создавались как slin:slin и git на
# хосте работал без ручного chown. Доступ к docker.sock сохранён через
# group_add: ["999"] (МИНА 1 — НЕ удалять). См. ADR-001 ORCH-040.
user: "1000:1000"
# init: true injects docker-init (tini) as PID 1 so reparented grandchild
# processes from the claude/node subprocess tree are reaped (no zombies, B-2).
init: true
@@ -15,7 +20,8 @@ services:
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
env_file: .env
environment:
- ORCH_REPOS_DIR=/repos
@@ -35,6 +41,8 @@ services:
build: .
container_name: orchestrator-staging
restart: unless-stopped
# ORCH-040: тот же uid хоста, что и у prod (см. комментарий выше / ADR-001).
user: "1000:1000"
init: true
network_mode: host
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
@@ -46,7 +54,8 @@ services:
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
env_file: .env.staging
environment:
- ORCH_REPOS_DIR=/repos

View File

@@ -9,9 +9,8 @@
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. **Валидация результата (ORCH-044):** `exit_code==0` считается успехом только если run-лог непустой и содержит валидный result-JSON; пустой/невалидный результат ⇒ job `failed`/retry + алерт, без авто-advance и «успешного» коммента.
- **Preflight** (`src/preflight.py`, ORCH-1/ORCH-044) — дешёвый token-free гейт клейма: `os.path.exists(bin)` + `claude --version` + **проверка авторизации** (чтение `<AGENT_HOME>/.claude/.credentials.json` и валидности `claudeAiOauth.expiresAt`; постфактум-маркер `Not logged in`). Кешируется на `preflight_cache_ttl`. Подробнее: [ADR work-item ORCH-044](../work-items/ORCH-044/06-adr/ADR-001-preflight-auth-and-empty-result-failure.md).
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. Не клеймит job при `preflight=fail` (в т.ч. auth-fail).
- **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.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
@@ -35,17 +34,57 @@ created → analysis → architecture → development → review → testing →
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
| done | — | — | — |
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status.
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043).
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
### Условный 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).
### 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 завершился»).
Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием:
- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`.
- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД.
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**.
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
(детерминированно, без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
прод-deployer выставляется approval-pending статус Plane + запрос approve
(Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status`
и merge-gate.
- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии
не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный
авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без миграции БД.
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
## Откаты
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
- Deploy / deploy-staging FAILED → откат на `development`.
- Merge-gate FAIL (конфликт rebase / красный re-test, ORCH-043) → откат на `development` + retry; `merge-lock busy`**defer** (не откат, dev-retry не тратится).
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
### Обогащение `task_desc` при заворотах (ORCH-046)
@@ -98,4 +137,4 @@ created → analysis → architecture → development → review → testing →
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-05 (main `f1b3146`). Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py.*
*Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043. ORCH-036: исполняемый самодеплой стадии `deploy` — design (см. adr-0007), реализация в ветке feature/ORCH-036.*

View File

@@ -9,6 +9,8 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0002 | Очередь задач вместо in-process потоков | accepted | 2026-06-03 | ORCH-1 |
| adr-0003 | Условный staging-гейт перед прод-деплоем | accepted | 2026-06-05 | ORCH-35 |
| adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 |
| 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 |
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

View File

@@ -0,0 +1,42 @@
# adr-0005: Контейнеры оркестратора бегут под uid:gid хоста (1000:1000)
- **Статус:** accepted
- **Дата:** 2026-06-06
- **Задача:** ORCH-040
## Контекст
Оба контейнера (`orchestrator`, `orchestrator-staging`) запускались под `uid=0 (root)` и
монтировали хостовый `/home/slin/repos``/repos` (rw). Claude-CLI агенты исполняются
`subprocess.Popen` внутри контейнера под тем же root, поэтому все артефакты конвейера
(git worktree, коммиты в `docs/`) появлялись на хосте как `root:root`. Деплой прода под
`slin` (uid 1000) ломался на правах git до ручного `chown`. Это сквозное свойство рантайма:
касается агентов **всех** проектов, а не отдельной фичи.
## Решение
Оба сервиса в `docker-compose.yml` запускаются под `user: "1000:1000"` (uid:gid хоста `slin`).
- `group_add: ["999"]` сохраняется — доступ к docker.sock идёт через gid 999, не через root.
- target SSH-маунта приведён к `/home/slin/.ssh` (был `/root/.ssh`), синхронно с
`HOME=/home/slin`, который форсит launcher → единый HOME по осям uid/claude/ssh.
- Образ и launcher не меняются: numeric uid не требует записи в `/etc/passwd`,
`git config --system safe.directory '*'` уже есть.
Обязательные host-prerequisites (Owner, вне кода): доступ uid 1000 к
`/home/slin/.claude/.credentials.json` (блокер), ssh-ключи в новом HOME, рестарт prod
только в окно тишины. Детали и команды — work-item ADR-001 и `docs/operations/INFRA.md`.
## Альтернативы
- **drop-privileges только для subprocess агента** (`gosu`/`setuid`) — контейнер остаётся
root; новый код в горячем пути launcher, два uid в одном контейнере; отклонён.
- **chown-хук после каждой стадии** — лечит симптом, требует root внутри контейнера
(несовместимо), хрупкий пост-шаг; отклонён (fallback на крайний случай).
## Последствия
- Артефакты создаются под `slin:slin`; деплой прода не требует ручного `chown`.
- HOME консистентен (uid = claude = ssh = `/home/slin`); устранён рассинхрон SSH-маунта.
- Появляется явная привязка рантайма к uid 1000 хоста (задокументирована в INFRA.md).
- Прод-рестарт self = групповой риск (общий инстанс с enduro-trails) → строго окно тишины;
страховка — staging-гейт (adr-0003).
## Связи
adr-0003 (staging-гейт — обязательная проверка перед прод-рестартом self),
adr-0001 (`is_self_hosting_repo`), work-item `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`.

View File

@@ -0,0 +1,53 @@
# adr-0006: Merge-gate — догон `main` + re-test + сериализация слияний
- **Статус:** proposed
- **Дата:** 2026-06-06
- **Задача:** ORCH-043
- **Детальный ADR:** `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`
## Контекст
Ветка валидируется относительно того `main`, из которого создана, а не относительно `main`
на момент слияния. Параллельная задача могла влиться раньше → **семантический конфликт
слияния** (git мержит без текстового конфликта, но `main` сломан). Для self-hosting это
красный `main` инструмента, обслуживающего все проекты. Слияние в `main` делает
deployer-агент в начале стадии `deploy`; замена механизма PR-merge — вне объёма.
## Решение
Детерминированный merge-gate (`check_branch_mergeable`, без LLM) на ребре
`deploy-staging → deploy`, ДО запуска deployer'а, который мержит. `STAGE_TRANSITIONS` не
меняется (минимальный blast-radius); в `QG_CHECKS` добавлен `check_branch_mergeable`.
- **Догон:** ветка отстаёт ⇔ `origin/main` не предок HEAD → `rebase origin/main` в worktree
+ `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт →
`rebase --abort` → откат на `development`.
- **Re-test:** `python -m pytest tests/` в worktree догнанной ветки, тайм-аут
`merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (BR-5):** файловый **merge-lease** на репо
(`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge.
Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer**
(re-enqueue deployer с задержкой через `available_at`), не rollback. Release — на
PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe.
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги
`merge_gate_enabled` / `merge_gate_repos` для поэтапного раската.
## Альтернативы
- **Новая стадия `merge-gate`** (кандидат B) — «пустая» стадия без агента не имеет триггера
(`advance_stage` срабатывает только на завершении агента/вебхуке); потребовала бы chaining
в движке (не restart-safe) или синтетический job-тип. Отклонено.
- **Перенос merge в детерминированный шаг оркестратора** (кандидат C) — запрещён объёмом
(замена механизма PR-merge вне scope). Отклонено.
- **Блокирующий lock** — дедлок при одном worker-слоте. Отклонено в пользу defer.
## Последствия
- Сценарий «две зелёные ветки ломают `main`» закрыт: re-test против актуального `main` +
сериализация слияний.
- Плата: merge-gate — «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); сериализация
опирается на PR-merged вебхук со страховкой реклеймом по возрасту; defer перепрогоняет
staging; длинный re-test держит worker-слот.
- Сквозное изменение конвейера → `arch:major-change`; прод-деплой ORCH-043 строго через
staging-гейт (8501).
## Связи
adr-0001 (`is_self_hosting_repo`), adr-0003 (условный staging-гейт — образец условности),
adr-0002 (очередь / `available_at` для defer), ORCH-2 (worktree-изоляция), ORCH-046
(дословный reason в `task_desc` при откате).

View File

@@ -0,0 +1,64 @@
# ADR-0007: Исполняемый самодеплой стадии `deploy` (Вариант B, ORCH-36)
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-036`.
## Контекст
Стадия `deploy` была «бумажной»: deployer-агент писал `deploy_status:` в
`14-deploy-log.md`, гейт `check_deploy_status` парсил вердикт и двигал
`deploy → done`. Реального деплоя не было. ORCH-36 делает стадию исполняемой для
self-hosting (`orchestrator`), сохраняя прежний ssh-путь для остальных репо.
Три ограничения формируют дизайн (детально — `docs/work-items/ORCH-036/06-adr/ADR-001`):
1. **Self-restart**: рестарт прод-контейнера 8500 убивает in-container процесс →
рестарт делает ВНЕШНИЙ host-процесс.
2. **Status-only verdict model**: approve = смена статуса Plane на `Approved`
(комментарии не управляют конвейером).
3. **Гонка гейта**: вердикт нельзя читать до завершения асинхронного хука.
## Решение
Для self-hosting стадия `deploy` исполняется в три фазы детерминированным кодом
(без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — для self + `deploy_require_manual_approve=true`
вместо запуска прод-deployer выставляется approval-pending статус Plane + запрос
approve (Plane-коммент + Telegram). Перехват в `advance_stage` на ребре
`deploy-staging → deploy` (после `check_staging_status` и merge-gate).
- **Фаза B (Plane → Approved)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → `orchestrator-deploy-hook.sh`
с прод-параметрами и build-once retag) и ставит **детерминированный finalizer-job**
с задержкой; маркер `initiated` — идемпотентность. Возврат БЕЗ advance.
- **Фаза C (finalizer)** — после рестарта новый контейнер дочитывает sentinel
`result` (exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет
`14-deploy-log.md`, вызывает `advance_stage(deploy, finished_agent="deployer")`
→ существующие контракты: `SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
### Ключевые инварианты (НЕ меняются)
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status` / `_parse_deploy_status`
(frontmatter only), откат БАГ-8, terminal-sync `deploy → done`, merge-gate (ORCH-43),
exit-code-контракт хука (0/1/2).
### Новое (сквозное)
- **Детерминированный job-kind** `deploy-finalizer` в очереди (reserved-agent, не
LLM): read-result | defer | map+write+advance. Зеркалит детерминизм merge-gate.
- **Approve-флаг** `deploy_require_manual_approve` (дефолт `true`; полный авто —
отдельная задача после набора метрик доверия, ORCH-54).
- **Build-once**: опциональный `SOURCE_IMAGE` retag в хуке (обратно совместимо).
- **Restart-safe состояние** деплоя — sentinel-файлы под
`<repos_dir>/.deploy-state-<repo>/<wi>/` (как merge-lease), БЕЗ миграции БД.
### Условность
Вся логика — только для `is_self_hosting_repo(repo)` (как ORCH-35). Прочие репо
деплоятся прежним синхронным ssh-путём агентом.
## Последствия
- `deploy_status: SUCCESS` доказан реальным health-ok; критический путь self-restart
детерминирован.
- Вводится новая под-компонента (finalizer job-handler) → изменение помечено
`arch:major-change`.
- Approve вписан в status-only модель: restart-safe, аудируемо, идемпотентно.
- На старте — обязательный ручной approve; молчаливых деплоев нет (Plane+Telegram).
## Связанные ADR
`adr-0003` (staging-gate), `adr-0006` (merge-gate), `adr-0005` (run-as-host-uid).
Детальный per-work-item: `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.

View File

@@ -88,16 +88,7 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
1. Записывает run в DB (agent_runs)
2. Запускает subprocess. **stdout/stderr перенаправляются СРАЗУ в файл `/app/data/runs/{id}.log` на уровне ОС** (Popen `stdout=log_fh`). Никакого PIPE в памяти оркестратора → нет PIPE-deadlock, нет потока-читателя, нет зомби (B-2).
3. Стартует **watchdog thread** (timeout 30 мин → SIGKILL по pid)
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → **валидация результата (ORCH-044)** git commit/push → auto-advance
**Валидация результата (ORCH-044, P3).** `exit_code==0` сам по себе НЕ считается успехом: claude может «быстро умереть» (разлогинен / флаг гасит stdout), оставив пустой или JSON-less лог, но выйдя с кодом 0 — раньше это было неотличимо от успеха (`done` + auto-advance по пустому результату). Теперь `_monitor_agent` вызывает `_validate_result(output_path)`:
- лог отсутствует / пустой (0 байт или только whitespace) ⇒ невалиден;
- нет парсящегося trailing result-JSON (тот же контракт, что usage-учёт — `usage._extract_last_json_object`) ⇒ невалиден;
- хелпер защитный (never-raise); при собственной ошибке — fail-safe в сторону провала.
`success = (exit_code==0 AND result_ok)`. Реальный `exit_code` пишется в `agent_runs` без искажения; на решение done/fail влияет отдельный флаг `result_ok` (не подменённый код выхода). Только при `success`: постится «успешный» status-коммент и вызывается `_try_advance_stage`. При `exit_code==0 AND not result_ok`: шлётся Telegram-алерт о пустом/невалидном результате, стадия НЕ двигается, а `_finalize_job(result_ok=False)` маршрутизирует job в провал (`empty run log / no result JSON`): по умолчанию permanent (`attempts<max` ⇒ requeue, иначе `failed`+алерт), transient-маркер в логе уводит в transient-путь. Итог: `exit_code==0` всегда завершается терминально/ретраябельно (`done`|`failed`|`queued`) — путь «быстрая смерть с exit 0 → вечный running» закрыт.
**Постфактум auth-детекция (ORCH-044, P1b).** В пути провала `_handle_auth_marker(log)` ищет маркер разлогина (`not logged in` / `please run /login` / `unauthorized` / `401`) и при совпадении сбрасывает preflight-кеш (`preflight.reset_cache()`), чтобы следующий тик воркера переоценил auth проактивно. Auth-провал НЕ transient и НЕ крутит circuit breaker.
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → git commit/push → auto-advance
### 5. Auto-advance (`launcher._try_advance_stage`)
@@ -116,6 +107,27 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
2. Если < MAX_DEV_RETRIES (3) — откатывает в development, перезапускает developer
3. Если >= MAX_DEV_RETRIES — эскалация (логирование + уведомление)
### 7. Live Telegram tracker (`src/notifications.py`)
Вместо ~15 отдельных сообщений на задачу оркестратор держит **ОДНУ** live-карточку на задачу (`update_task_tracker`), которая обновляется на каждом переходе стадии. Текст рендерится статически из БД (`render_task_tracker`: стадии, токены, стоимость, BRD-подтверждение, итоги). Карточка всегда тихая (`disable_notification=True`); отдельные пинги шлют только `notify_approve_requested` / `notify_error`. `message_id` хранится в `tasks.tracker_message_id`; helpers `get_tracker_message_id` / `set_tracker_message_id`. Контракт всего компонента — **never raises**.
**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → нулевая регрессия и безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.
| Режим | Поведение при обновлении |
|-------|--------------------------|
| `edit` (дефолт) | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). |
| `bump` | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)``send_telegram(text, disable_notification=True)``set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. |
**`delete_telegram(message_id) -> bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»:
- `ok:true``True`;
- `ok:false` с маркерами `_DELETE_GONE_MARKERS` (`message to delete not found`, `message can't be deleted`, `message_id_invalid`) → `True` (старше 48ч / уже удалено — не транзиент);
- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`;
- нет токена/chat_id → `False`, HTTP не выполняется.
Результат `delete_telegram` **не** блокирует отправку новой карточки (BR-6: delete-fail у сообщения >48ч → всё равно шлём новое); `False` означает лишь «старое, возможно, ещё живо» — будет вычищено повторной попыткой на следующем переходе. При транзиентном сбое send (`None`) указатель `tracker_message_id` **не** затирается (анти-затирание, симметрично edit-fallback).
**Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются.
## Database Schema
```sql
@@ -225,8 +237,6 @@ services:
| Max retries | Developer: max 3 попытки, затем эскалация |
| Zombie-free | stdout идёт сразу в файл + monitor `proc.wait()` → процесс всегда reap'нут (B-2) |
| Orphan recovery | При старте: orphan-run'ы (finished_at IS NULL, старше 35 мин) помечаются exit=-1 с per-run warning + Telegram-уведомлением «нужна ручная проверка» (M-1) |
| Preflight auth-гейт (ORCH-044) | Перед клеймом: `os.path.exists(bin)` + `claude --version` + **token-free auth** (чтение `.credentials.json` + `expiresAt`); разлогинен / протух ⇒ job не клеймится. Постфактум-маркер `not logged in` сбрасывает кеш. Тумблер `ORCH_PREFLIGHT_CHECK_AUTH`. Детали — INFRA.md |
| Пустой результат = провал (ORCH-044) | `exit 0` с пустым/JSON-less логом ⇒ `failed`/retry + алерт, без auto-advance (см. §4 «Валидация результата») |
## Агенты
@@ -303,15 +313,12 @@ webhook (plane/gitea) background thread (queue_worker)
_monitor_agent (proc.wait, commit/push,
│ advance stage)
_finalize_job(result_ok):
exit 0 & result_ok -> mark_job done
else (exit!=0 ИЛИ пустой результат):
attempts<max -> requeue (queued)
attempts>=max -> failed + Telegram
_finalize_job:
exit 0 -> mark_job done
exit !=0 & attempts<max -> requeue (queued)
exit !=0 & attempts>=max -> failed + Telegram
```
> ORCH-044 (P3): `result_ok` отражает валидность run-лога (непустой + есть result-JSON). `exit 0` с пустым/невалидным результатом идёт в ветку провала, НЕ в `done` (см. §4 «Валидация результата»).
### Таблица `jobs`
| Колонка | Назначение |

View File

@@ -8,6 +8,7 @@
1. **Захват текущего образа** — до рестарта записывает ID образа работающего контейнера в `$PREV_IMAGE_FILE` (best-effort, не падает если сервис не запущен).
2. **git pull** — обновляет код репозитория.
2b. **Build-once retag** (ORCH-036, BR-6) — если задан `$SOURCE_IMAGE`, хук ретегает его на `$TARGET_IMAGE` (`docker tag $SOURCE_IMAGE $TARGET_IMAGE`) и поднимает контейнер на этом образе через `up -d --no-build`. Это деплой РОВНО того образа, что прошёл staging, **без `docker build`**. Если `$SOURCE_IMAGE` не задан (дефолт) — шаг пропускается (обратная совместимость).
3. **Рестарт контейнера**`docker compose --profile $COMPOSE_PROFILE up -d --no-build $TARGET_SERVICE`.
4. **Health-цикл** — 10 попыток × 6с = до 60с. Критерий: HTTP 200 + тело содержит `"status":"ok"`.
- **Успех** → `exit 0`, лог "Deploy SUCCESS".
@@ -29,6 +30,7 @@
| `TARGET_IMAGE` | `orchestrator-orchestrator-staging` | Имя образа для retag при rollback |
| `COMPOSE_PROFILE`| `staging` | Docker compose profile (пусто = без профиля) |
| `PREV_IMAGE_FILE`| `$REPO/.deploy-prev-image-staging`| Файл для сохранения предыдущего образа |
| `SOURCE_IMAGE` | _(unset)_ | Build-once (ORCH-036): провалидированный образ для retag на `$TARGET_IMAGE` перед рестартом (без rebuild). Не задан → шаг пропущен. |
| `LOG` | `/var/log/orchestrator/deploy-hook.log` | Лог-файл (fallback: `$REPO/deploy-hook.log`) |
> ⚠️ **Дефолт — всегда STAGING**. Прод активируется только явным переопределением env.
@@ -55,6 +57,20 @@ PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
### Прод build-once (ORCH-036) — ретег staging-образа, без rebuild
Так прод-деплой запускается **автоматически** исполняемым самодеплоем (Фаза B: `ssh + setsid`, см. `INFRA.md`). Ключевое отличие — `SOURCE_IMAGE` указывает на провалидированный staging-образ, который ретегается на прод-тег:
```bash
SOURCE_IMAGE=orchestrator-orchestrator-staging \
TARGET_SERVICE=orchestrator \
TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator \
COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
### Ручной rollback staging
```bash

View File

@@ -30,12 +30,33 @@
Оба: `network_mode: host`, `init: true` (tini как PID 1 — reaping зомби, B-2), `restart: unless-stopped`.
### Рантайм-uid (ORCH-040)
Оба сервиса бегут под `user: "1000:1000"` (slin), **не** root. Артефакты конвейера
(git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) создаются как
`slin:slin`, поэтому `git pull` / `git reset` на хосте под slin работают без ручного
`chown`. Доступ к docker.sock сохранён через `group_add: ["999"]` (gid docker, **не**
через root — НЕ удалять). При переносе на другой хост uid пересматривается. См.
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`.
**Host-prerequisites (обязательная процедура Owner, в git не коммитятся):**
- **P-1 (блокер):** uid 1000 читает claude creds — `chown -R 1000:1000 /home/slin/.claude`;
проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json`. Без этого
preflight (ORCH-044) заворачивает весь конвейер.
- **P-2:** ssh-ключи в `/home/slin/.orchestrator-ssh` читаемы uid 1000 (маунт ведёт в `/home/slin/.ssh`).
- **P-3:** `id slin``1000:1000`; `/repos`, `/app/data` уже `1000:1000`.
- **P-4:** прод-рестарт self — только в окно тишины (`GET /status` без активных задач):
общий инстанс с enduro-trails.
- Разовый разгребающий `chown -R 1000:1000 /home/slin/repos/orchestrator` для старых
`root:root` файлов из истории (вне объёма кода).
### Тома (volumes)
- `./data``/app/data` (БД; у staging — `./data/staging`)
- `/home/slin/repos``/repos` (рабочие репозитории проектов)
- `/var/run/docker.sock` (для docker-операций деплоя)
- claude-code, node, `~/.claude*` (CLI агентов, ro)
- `~/.orchestrator-ssh``/root/.ssh` (ro, деплой по ssh)
- `~/.orchestrator-ssh``/home/slin/.ssh` (ro, деплой по ssh; target в HOME агента,
согласован с `HOME=/home/slin` из launcher — ORCH-040, ранее `/root/.ssh`)
## Переменные окружения (карта; значения — в `.env`)
@@ -54,10 +75,14 @@
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
| `ORCH_PREFLIGHT_CHECK_AUTH` | вкл/выкл token-free auth-проверку preflight (ORCH-044); дефолт `true`. Аварийный тумблер: `false`preflight как до ORCH-044 (только `--version`) |
| `ORCH_CLAUDE_CREDENTIALS_PATH` | явный путь к `.credentials.json` (ORCH-044); пусто → `<AGENT_HOME>/.claude/.credentials.json`, где `AGENT_HOME=/home/slin` — HOME, под которым launcher реально спавнит claude (не HOME процесса орка) |
| `ORCH_AUTH_EXPIRY_SKEW_SECONDS` | запас на рассинхрон часов при сравнении `claudeAiOauth.expiresAt` (ORCH-044); дефолт `0` |
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
| `ORCH_SELF_DEPLOY_ENABLED` | ORCH-036 kill-switch исполняемого самодеплоя (true); false → legacy-путь для всех |
| `ORCH_SELF_DEPLOY_REPOS` | CSV репозиториев с реальным самодеплоем; пусто → только self-hosting `orchestrator` |
| `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` | требовать человеческий Plane «Approved» для прод-деплоя (true, безопасно) |
| `ORCH_DEPLOY_FINALIZE_DELAY_S` / `_MAX_ATTEMPTS` | задержка и бюджет defer'ов finalizer'а (Фаза C; 90 / 10) |
| `ORCH_DEPLOY_SSH_USER` / `_SSH_HOST` | куда запускается detached хост-деплой (Фаза B, `ssh user@host`) |
| `ORCH_DEPLOY_HOOK_SCRIPT` / `_HOST_REPO_PATH` | путь к хук-скрипту (отн. репо) и чекаут orchestrator на хосте |
| `ORCH_DEPLOY_PROD_SOURCE_IMAGE` | staging-образ для build-once retag на прод-тег (без rebuild) |
| `ORCH_DEPLOY_PROD_TARGET_SERVICE` / `_TARGET_PORT` / `_TARGET_IMAGE` / `_COMPOSE_PROFILE` / `_PREV_IMAGE_FILE` | прод-цель хука + снапшот для авто-rollback |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
@@ -84,19 +109,6 @@
> ⚠️ Бюджет (ORCH-38): `claude-opus-4-8` дефолт в коде; реальное переключение прод-env делается отдельно после согласования.
## Preflight auth-гейт (`src/preflight.py`, ORCH-044)
`claude --version` отвечает успешно **даже когда claude разлогинен** (версия — локальная инфа), поэтому до ORCH-044 preflight был слеп к авторизации: разлогиненный инстанс клеймил job и тихо умирал с пустым логом, блокируя общую очередь всех проектов.
ORCH-044 добавляет **token-free** проверку (без сети, без prompt-ping — BR-1):
1. **Проактивно (основной гейт):** после успешного `--version` читается `<AGENT_HOME>/.claude/.credentials.json` (путь — `ORCH_CLAUDE_CREDENTIALS_PATH` или дефолт от `AGENT_HOME=/home/slin`, **не** HOME процесса орка). Нет файла / битый JSON / нет `claudeAiOauth.accessToken``check()=(False, …)`. `claudeAiOauth.expiresAt` (epoch ms) `<= now + ORCH_AUTH_EXPIRY_SKEW_SECONDS` ⇒ протух ⇒ FAIL. Нет `expiresAt` ⇒ OK (не плодим ложные срабатывания). Результат кешируется тем же `ORCH_PREFLIGHT_CACHE_TTL`, что и `--version`.
2. **Постфактум (защитная сетка):** если агент всё же стартовал при протухшей сессии, launcher детектит маркер (`not logged in` / `please run /login` / `unauthorized` / `401`) в run-логе и сбрасывает preflight-кеш, чтобы следующий тик переоценил auth. Auth-провал **не** считается transient и **не** крутит circuit breaker — гейт здесь preflight.
При `auth=fail` job **не клеймится** (`_drain_once` уже корректен при `ok=False`), reason виден в `/queue` (`preflight_reason`). Аварийный тумблер `ORCH_PREFLIGHT_CHECK_AUTH=false` возвращает version-only поведение.
> ⚠️ Риск ложноположительного auth-fail (R-1): неверный путь к credentials заблокирует клейм **всех** проектов (общая очередь). Митигация: единый источник `AGENT_HOME`, тумблер, обязательная проверка на staging (8501) перед прод-деплоем. ADR — `docs/work-items/ORCH-044/06-adr/ADR-001-preflight-auth-and-empty-result-failure.md`.
> `--effort` (P2) в ORCH-044 **не трогается** — вынесен в ORCH-50.
## ⚠️ Self-hosting — оркестратор дорабатывает САМ СЕБЯ
**Факт:** прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты (enduro-trails + orchestrator), с ОБЩЕЙ БД `./data/orchestrator.db` и общей очередью задач (ORCH-1).

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH-36: Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)
Work Item ID: ORCH-036
## Description
TBD

View File

@@ -0,0 +1,109 @@
# BRD — ORCH-36: Исполняемый самодеплой (стадия deploy дёргает хост-хук, Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
## 1. Контекст и проблема
Стадия `deploy` конвейера сейчас **«бумажная»**. На ней deployer-агент (LLM) только
пишет `docs/work-items/<wi>/14-deploy-log.md` с `deploy_status: SUCCESS|FAILED`, а QG
`check_deploy_status` (`src/qg/checks.py:464`) парсит этот вердикт и пускает `deploy → done`.
**Реального docker-деплоя нет** — продакшен орка катается руками (Стрим).
Хост-хук `scripts/orchestrator-deploy-hook.sh` **уже существует** (ORCH-34) и умеет:
захват PREV_IMG → `git pull` → рестарт сервиса → health-check (10×6с = 60с) →
авто-rollback при провале health, с корректным exit-code. Дефолты — STAGING-безопасные;
прод включается через override env (`TARGET_SERVICE`, `TARGET_PORT`, `TARGET_IMAGE`,
`COMPOSE_PROFILE`).
**Главная мина (self-hosting):** прод-контейнер `orchestrator` (8500) — ОДИН на все
проекты, и в нём же бежит сам deployer-агент. Deployer не может синхронно рестартить
контейнер, в котором живёт (`docker compose up -d orchestrator` убьёт его процесс на
середине). Реальный рестарт self-репо обязан делать ВНЕШНИЙ хост-хук (вне контейнера),
который срабатывает ПОСЛЕ выхода агента. Рубильник — снаружи; орк только ИНИЦИИРУЕТ.
## 2. Цель
Превратить стадию `deploy` в РЕАЛЬНЫЙ самодеплой: после зелёного `deploy-staging`-гейта
конвейер вызывает хост-хук с прод-параметрами, хук промоутит образ в прод (8500) с
health-чеком и авто-rollback. Результат хука (exit-code) маппится в `deploy_status`.
**На старте — с ОБЯЗАТЕЛЬНЫМ ручным approve** (`DEPLOY_REQUIRE_MANUAL_APPROVE=true`):
прод не трогается без явного «go» Владельца.
## 3. Ценность для бизнеса
- Уходит последний ручной шаг конвейера (прод-деплой Стрим) → шаг к автономному внедрению (эпик ORCH-54).
- `deploy_status: SUCCESS` становится **доказанным** (реальный health-ok), а не декларацией LLM.
- Гарантия build-once: «что протестировали на staging — то и в проде» (тот же образ, без пересборки).
- Прод никогда не остаётся в нерабочем состоянии: авто-rollback + health-таймаут.
## 4. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Владелец (Слава/Стрим) | Контроль через ручной approve; уведомления о каждом промоуте/откате |
| Проект enduro-trails | Прод-орк не должен падать (общий инстанс) — групповой риск |
| Конвейер ORCH | Стадия `deploy` исполняемая, гейты не сломаны |
## 5. Объём (scope)
### В объёме
1. Исполнение реального прод-деплоя из стадии `deploy` через хост-хук (ssh / detached на хосте).
2. Обязательный ручной approve-гейт ПОСЛЕ зелёного staging и ДО прод-рестарта (флаг включён).
3. Маппинг exit-code хука → `deploy_status: SUCCESS|FAILED` (реальный, не бумажный).
4. Уведомления (Plane-коммент + Telegram) на каждый промоут и откат.
5. Build-once: перетегирование образа, прошедшего staging, без пересборки.
6. Обновление `deployer.md` и `INFRA.md` (документация = golden source).
7. Для НЕ-self репо (enduro-trails и др.) — деплой по ssh на их хост (поведение не ломается).
### Вне объёма (явно)
- **Включение полного авто** (`DEPLOY_REQUIRE_MANUAL_APPROVE: true → false`) — отдельная задача,
ТОЛЬКО после набора метрик доверия (см. §7). В этой задаче флаг НЕ выключается.
- Изменение `docker-compose.yml` без явной необходимости.
- Изменение стадий `STAGE_TRANSITIONS`, реестра QG, terminal-sync `deploy → done`.
- Прод-деплой в реальный бой во время разработки задачи (отладка — только на staging-цели хука).
## 6. Бизнес-требования
- **BR-1.** После зелёного `deploy-staging`-гейта стадия `deploy` РЕАЛЬНО собирает/перетегирует
образ, рестартит целевой сервис и проверяет health — не пишет бумажный SUCCESS.
- **BR-2.** Для self-репо `orchestrator` рестарт 8500 выполняется ВНЕШНИМ (detached/host)
процессом; deployer-агент НЕ убивает контейнер, в котором работает.
- **BR-3.** `deploy_status: SUCCESS` пишется ТОЛЬКО при health-ok хука; провал/health-fail →
`deploy_status: FAILED` → откат на `development` (как ORCH-35 staging-rollback, БАГ-8).
- **BR-4.** Ручной approve обязателен (флаг `true`): без явного «go» прод НЕ трогается.
- **BR-5.** Каждый промоут и откат уведомляет Владельца: Plane-коммент в задачу + Telegram.
«Молчаливых» деплоев нет.
- **BR-6.** Build-once: в прод идёт тот образ, что прошёл staging-гейт (перетег, не пересборка).
- **BR-7.** Staging-гейт (`check_staging_status`) остаётся обязательным предусловием прод-деплоя.
- **BR-8.** Прод никогда не остаётся в нерабочем состоянии — авто-rollback при провале health.
- **BR-9.** Существующие гейты и инварианты не ломаются: `check_deploy_status`,
`_parse_deploy_status`, rollback `deploy → development` (БАГ-8), terminal-sync `deploy → done`,
merge-gate (ORCH-43).
- **BR-10.** Документация (`deployer.md`, `INFRA.md`, `CHANGELOG.md`) обновлена в том же PR.
## 7. Критерии готовности к включению ПОЛНОГО авто (вне этой задачи)
Переключать `DEPLOY_REQUIRE_MANUAL_APPROVE: true → false` можно ТОЛЬКО когда закрыты ВСЕ 5:
1. ≥10 успешных промоутов подряд (staging зелёный → approve → прод поднялся, откат не нужен).
2. Zero false-negative: staging-гейт ни разу не пропустил битый деплой как «зелёный».
3. Авто-rollback проверен в бою (≥23 реальных срабатывания), recovery 100%, MTTR < 60с.
4. Ни одного «молчаливого» деплоя (каждый промоут/откат уведомил Владельца).
5. Период наблюдения ≥10 деплоев ИЛИ ≥2 недели без инцидентов в режиме manual-approve.
## 8. Риски
| Риск | Влияние | Митигация |
|------|---------|-----------|
| Падение прод-орка 8500 при self-деплое | Встаёт конвейер ВСЕХ проектов | Detached host-хук + health + авто-rollback; отладка на staging-цели |
| Deployer рестартит сам себя синхронно | Процесс агента убит на середине | BR-2: рестарт только внешним detached-процессом |
| Преждевременный `deploy_status: SUCCESS` (хук ещё не закончил) | Задача уходит в done при незавершённом деплое | Гейт читает РЕАЛЬНЫЙ исход хука (механизм — на дизайне) |
| Деплой без approve | Неконтролируемый прод-деплой | BR-4: approve-гейт блокирует до «go» |
| Пересборка вместо перетега | В прод уезжает не то, что тестировали | BR-6: build-once, `--no-build` + retag |
## 9. Связанные задачи
ORCH-7 (self-hosting), ORCH-21 (auto-rollback), ORCH-34 (хук готов), ORCH-35 (staging-гейт),
ORCH-43 (merge-gate в проде), ORCH-54 (эпик автономного внедрения).
Дизайн-референс: `tasks/orchestrator/DESIGN_STAGING_ENV.md §4/§7`.

View File

@@ -0,0 +1,136 @@
# ТЗ — ORCH-36: Исполняемый самодеплой (стадия deploy дёргает хост-хук, Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
> Документ фиксирует ТРЕБОВАНИЯ к изменениям (что и где). Конкретный механизм
> (ssh vs docker.sock vs detached nohup/systemd-run; механизм approve) выбирает
> архитектор в ADR (`06-adr/`). ТЗ задаёт границы и контракты, не реализацию.
## 1. Текущее устройство (as-is, разведано в коде)
- **Стадии** (`src/stages.py`): `… testing → deploy-staging → deploy → done`.
- `deploy-staging`: `agent=deployer`, `qg=check_staging_status` (запускается deployer при
выходе из `deploy-staging`, входе в `deploy`).
- `deploy`: `agent=None`, `qg=check_deploy_status` (агент НЕ запускается при выходе из `deploy`).
- **Вывод:** реальную работу стадии `deploy` делает deployer-агент, запущенный на переходе
`deploy-staging → deploy`. Он пишет `14-deploy-log.md`. Когда он завершается, `advance_stage`
с `current_stage=deploy` прогоняет `check_deploy_status` и двигает `deploy → done`.
- **QG** (`src/qg/checks.py`):
- `check_deploy_status:464``_parse_deploy_status:406` читает ТОЛЬКО `deploy_status:` из
YAML-frontmatter `14-deploy-log.md` (worktree → origin/main fallback → not found).
- `check_staging_status:580` — условный (реален только для self-hosting `orchestrator`).
- `is_self_hosting_repo()` (`:511`) — детектор self-репо.
- **Откаты/диспетчеризация** (`src/stage_engine.py`):
- `_handle_qg_failure_rollbacks:585` — ветка `deployer` + `check_deploy_status` FAILED →
откат `deploy → development`, `set_issue_blocked`, release merge-lease, Plane+Telegram.
- Terminal-sync `deploy → done` (`:281`) → `set_issue_done`, release merge-lease.
- merge-gate (ORCH-43) на ребре `deploy-staging → deploy`НЕ трогать.
- **Launcher** (`src/agents/launcher.py`):
- deployer-агент конфиг: `.task-deploy.md` / `.openclaw/agents/deployer.md` (`:180`).
- Пост-обработка: commit+push артефактов в worktree (`:506-558`).
- `exit_code != 0 && agent == deployer` → откат `deploy → development` (`:560-581`).
- **Хост-хук** (`scripts/orchestrator-deploy-hook.sh`, ORCH-34) — ГОТОВ: `--deploy`/`--rollback`,
параметризован env, дефолты STAGING; health 10×6с; авто-rollback; exit 0/1/2.
- **Agent (deployer.md)**: на стадии `deploy` сейчас пишет «бумажный» вердикт; в промпте маркер
«Real docker/SSH deploys are handled by scripts/orchestrator-deploy-hook.sh (ORCH-36)».
- **Топология** (`docs/operations/INFRA.md`): prod=8500 (`.env`), staging=8501 (`.env.staging`,
profile staging). Контейнер под uid 1000, доступ к docker.sock через gid 999.
## 2. Изменения по модулям (to-be)
### 2.1 `scripts/orchestrator-deploy-hook.sh` (донастройка прод-режима)
- Хук уже параметризован; требуется обеспечить **корректный прод-профиль вызова**:
`TARGET_SERVICE=orchestrator`, `TARGET_PORT=8500`, `TARGET_IMAGE=orchestrator-orchestrator`,
`COMPOSE_PROFILE` (для прод-сервиса — пустой/дефолтный, т.к. prod стартует без profile).
- **Build-once (BR-6):** деплой должен использовать образ, прошедший staging (перетег
staging-образа → прод-тег + `docker compose up -d --no-build`), а НЕ пересобирать. Если
текущий хук всегда `--no-build` и тянет `git pull` — уточнить в ADR, как гарантируется
идентичность артефакта staging↔prod (retag staging image, либо общий build-once шаг).
- `PREV_IMAGE_FILE` для прод — отдельный путь (например `.deploy-prev-image` без `-staging`),
чтобы не путать снапшоты prod/staging.
- Поведение `--rollback`, health-loop, exit-code (0=ok, 1=rolled back, 2=rollback тоже упал) —
НЕ менять контракт.
### 2.2 Approve-гейт (новое; место — на дизайне)
- Ввести флаг конфигурации `DEPLOY_REQUIRE_MANUAL_APPROVE` (bool, дефолт `true`).
- При `true`: перед вызовом прод-хука (после зелёного `deploy-staging`) конвейер ОСТАНАВЛИВАЕТСЯ
и ждёт явного «go» Владельца. Без «go» прод-хук НЕ вызывается.
- Механизм approve (выбрать ОДИН в ADR): Plane-коммент-триггер (по образцу `:approved:`
в `check_analysis_approved`) / Telegram-кнопка / signal-файл. Требование к механизму:
рестарт-safe (переживает перезапуск инстанса), идемпотентный, аудируемый.
- При `false` (вне этой задачи): approve-шаг пропускается — НЕ реализовывать выключение здесь,
только заложить ветку по флагу.
### 2.3 Триггер реального деплоя из стадии `deploy`
- На стадии `deploy` (для self-репо `orchestrator`) вместо/в дополнение к записи вердикта
агентом — ИНИЦИИРОВАТЬ внешний detached-процесс (host-хук), который выполнит
build-once+restart+health ПОСЛЕ выхода агента (BR-2: агент не рестартит сам себя).
- Маршрут вызова (на дизайне): ssh на хост (`DEPLOY_SSH_USER`/`DEPLOY_HOOK_SCRIPT`) ИЛИ
detached через docker.sock/nohup/systemd-run. Требование: процесс хука переживает выход
агента и завершение его сессии.
- Для **не-self** репо (enduro-trails): деплой по ssh на их хост (как раньше) — поведение не ломать.
### 2.4 Маппинг результата хука → `deploy_status`
- `deploy_status: SUCCESS` пишется в `14-deploy-log.md` ТОЛЬКО при exit-code хука = 0 (health-ok).
- exit-code ≠ 0 (1 = rolled back; 2 = rollback тоже упал) → `deploy_status: FAILED`.
- **Контракт `_parse_deploy_status` НЕ меняется** (читает `deploy_status: SUCCESS|FAILED` из
frontmatter). Меняется только КТО и КОГДА пишет этот вердикт — на основе реального исхода.
- **Гонка чтения гейта:** т.к. self-рестарт асинхронный (detached), гейт `check_deploy_status`
не должен прочитать вердикт ДО завершения хука. Механизм синхронизации (post-factum запись
лога/мердж в main / отложенный гейт) — спроектировать в ADR так, чтобы гейт читал РЕАЛЬНЫЙ
итог. Контракт чтения из worktree→origin/main (`_deploy_log_from_main`) можно переиспользовать.
### 2.5 Уведомления (BR-5)
- На промоут (старт прод-деплоя + успех) и на откат → `plane_add_comment(work_item_id, …)` +
`send_telegram(…)`. Переиспользовать существующие хелперы (`src/notifications.py`,
`src/plane_sync.py`). Никаких «молчаливых» деплоев.
### 2.6 Конфигурация (`src/config.py` / `.env.example` / `.env.staging.example`)
- Новый: `deploy_require_manual_approve: bool = True` (env `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE`).
- Прод-параметры хука: `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST`, `DEPLOY_HOOK_SCRIPT` (уже есть в
INFRA-карте) + прод-override `TARGET_SERVICE/PORT/IMAGE`. Прописать дескрипторы в `.env.example`
(значения — только на хосте, не коммитить).
- Условность по репо: реальный прод-деплой — только для self-hosting (`is_self_hosting_repo`),
как ORCH-35; прочие репо идут прежним ssh-путём.
### 2.7 Документация (BR-10, golden source)
- `.openclaw/agents/deployer.md` — раздел «Stage: deploy»: переписать с «бумажного SUCCESS» на
«стадия ВЫЗЫВАЕТ хук»; зафиксировать запрет синхронного рестарта 8500 и detached-путь self.
- `docs/operations/INFRA.md` — процедура прод-деплоя орка через хук + approve.
- `docs/operations/DEPLOY_HOOK.md` — обновить, если затронут контракт хука.
- `CHANGELOG.md` — запись о включении исполняемого деплоя (manual-approve).
- ADR в `docs/work-items/ORCH-036/06-adr/ADR-NNN-*.md` (создаёт архитектор).
## 3. API
- Изменений публичного HTTP API (`/health`, `/status`, `/queue`, `/webhook/*`) **не требуется**.
- Если approve реализуется через Plane-коммент — переиспользуется существующий webhook-путь
(`POST /webhook/plane`), новый endpoint не вводится. Если через signal-файл/Telegram —
внешний по отношению к HTTP API механизм. Решение — ADR.
## 4. Схема БД
- Изменения схемы **не требуются** для базового сценария (вердикт — в `14-deploy-log.md`;
approve-состояние желательно хранить рестарт-safe — допустимо через jobs/task_content или
signal-файл, без новой таблицы). Если архитектор сочтёт нужным поле статуса approve —
обосновать в ADR; по умолчанию — без миграции.
## 5. Требования к Quality Gates
- `check_deploy_status` и `_parse_deploy_status` — контракт чтения НЕ менять (frontmatter only).
- Откат `deploy → development` при `deploy_status: FAILED` (`stage_engine` БАГ-8) — сохранить.
- Terminal-sync `deploy → done` и release merge-lease — сохранить.
- merge-gate (`check_branch_mergeable`) на ребре `deploy-staging → deploy` — не затрагивать.
- `check_staging_status` остаётся обязательным предусловием (BR-7).
## 6. Артефакты pipeline
- Создаётся/обновляется: `docs/work-items/ORCH-036/14-deploy-log.md` (с РЕАЛЬНЫМ `deploy_status`).
- Обновляются по pipeline: `06-adr/ADR-NNN-*.md`, `12-review.md`, `13-test-report.md`,
`15-staging-log.md` (последующими агентами).
## 7. Нефункциональные требования
- **Безопасность self-deploy:** рестарт 8500 — только внешним рубильником; орк не может
необратимо убить себя.
- **Идемпотентность** хука и approve-механизма; **рестарт-safe** approve-состояние.
- **MTTR < 60с** при авто-rollback (health-loop хука 10×6с уже укладывается).
- **Отладка только на staging-цели** хука; реальный прод — лишь после approve.

View File

@@ -0,0 +1,97 @@
# Критерии приёмки — ORCH-36: Исполняемый самодеплой (Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
Формат: каждый критерий — проверяемое условие PASS/FAIL. Отладка и проверки
выполняются на **staging-цели хука** (8501); реальный прод (8500) — только после approve.
---
## AC-1. Стадия deploy исполняет реальный деплой (не бумажный)
- **PASS:** на стадии `deploy` (после зелёного `deploy-staging`) вызывается хост-хук,
который реально перетегирует образ, рестартит целевой сервис и выполняет health-check;
`deploy_status` отражает РЕАЛЬНЫЙ исход хука.
- **FAIL:** `deploy_status: SUCCESS` пишется без фактического рестарта/health (бумажный лог).
- **Проверка:** прогон на staging-цели хука; в логе хука видны retag + `up -d` + health-loop;
exit-code хука соответствует записанному `deploy_status`.
## AC-2. Self-репо: рестарт 8500 — внешним detached-процессом, агент себя не убивает
- **PASS:** для `orchestrator` рестарт 8500 выполняет процесс ВНЕ контейнера агента; deployer-агент
завершается штатно (exit 0), его процесс не убит рестартом контейнера.
- **FAIL:** deployer синхронно делает `docker compose up -d orchestrator` из контейнера и/или
агент падает/обрывается на середине из-за рестарта собственного контейнера.
- **Проверка:** симуляция на staging-цели; убедиться, что detached-процесс переживает выход агента.
## AC-3. deploy_status маппится из exit-code хука
- **PASS:** exit-code хука 0 → `deploy_status: SUCCESS`; exit-code ≠ 0 (1/2) → `deploy_status: FAILED`.
- **FAIL:** любой иной маппинг (например SUCCESS при exit 1).
- **Проверка:** unit-тест маппинга exit-code → вердикт; интеграционный прогон с искусственным
кодом возврата хука.
## AC-4. Провал деплоя → откат на development
- **PASS:** при `deploy_status: FAILED` задача откатывается `deploy → development`
(`set_issue_blocked`, Plane+Telegram), как в существующей ветке БАГ-8.
- **FAIL:** при FAILED задача уходит в `done` или зависает.
- **Проверка:** существующий контракт `stage_engine._handle_qg_failure_rollbacks` для
`deployer`+`check_deploy_status` сохранён и срабатывает.
## AC-5. Ручной approve обязателен и реально тормозит прод
- **PASS:** при `DEPLOY_REQUIRE_MANUAL_APPROVE=true` прод-хук НЕ вызывается до явного «go»;
после «go» — вызывается.
- **FAIL:** прод-хук дёргается без approve.
- **Проверка:** прогон без «go» — целевой сервис НЕ перезапущен (нет записи рестарта в логе хука,
не сменился uptime/контейнер); прогон с «go» — рестарт состоялся.
## AC-6. Уведомления о каждом промоуте и откате
- **PASS:** на старт/успех прод-деплоя и на откат приходят и Plane-коммент в задачу, и Telegram.
- **FAIL:** хотя бы один промоут/откат прошёл «молчаливо».
- **Проверка:** в Plane-задаче и в Telegram-чате присутствуют сообщения для каждого исхода.
## AC-7. Build-once: в прод идёт образ, прошедший staging
- **PASS:** прод-деплой использует тот же образ, что прошёл staging-гейт (retag + `--no-build`),
без пересборки.
- **FAIL:** прод-деплой пересобирает образ заново (артефакт может отличаться от протестированного).
- **Проверка:** sha/тег образа прод == образ, валидированный на staging; в логе нет `build`.
## AC-8. Staging-гейт остаётся обязательным предусловием
- **PASS:** прод-деплой недостижим без зелёного `check_staging_status` (`staging_status: SUCCESS`).
- **FAIL:** прод-хук можно вызвать при FAILED/отсутствующем staging-вердикте.
- **Проверка:** при `staging_status: FAILED` задача откатывается на development, до `deploy` не доходит.
## AC-9. Авто-rollback восстанавливает прод (симуляция битого деплоя)
- **PASS:** при симуляции битого деплоя на staging-цели health не проходит → хук авто-откатывает
на предыдущий образ → сервис снова healthy; exit-code = 1 (rolled back); MTTR < 60с.
- **FAIL:** сервис остаётся нерабочим после провала деплоя.
- **Проверка:** искусственно сломать health, прогнать хук, убедиться в восстановлении и exit 1.
## AC-10. Существующие инварианты не сломаны
- **PASS:** не изменены контракты `check_deploy_status` / `_parse_deploy_status`,
`STAGE_TRANSITIONS`, terminal-sync `deploy → done`, merge-gate (ORCH-43), rollback БАГ-8.
- **FAIL:** любой из перечисленных контрактов изменён/сломан.
- **Проверка:** существующие тесты deploy/staging/merge-gate зелёные; регресс-прогон `pytest tests/`.
## AC-11. Условность по репо (не-self не ломается)
- **PASS:** для не-self репо (enduro-trails) деплой идёт прежним ssh-путём; self-логика (detached,
approve, 8500) применяется только для `orchestrator`.
- **FAIL:** не-self репо затронуты self-специфичной логикой и ломаются.
- **Проверка:** `is_self_hosting_repo` корректно разводит пути; тест на не-self репо.
## AC-12. Флаг полного авто НЕ выключен в этой задаче
- **PASS:** `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true`; переключение в `false` не делается.
- **FAIL:** флаг выставлен в `false` в рамках задачи.
- **Проверка:** дефолт конфигурации = `true`; в коде/`.env.example` нет принудительного `false`.
## AC-13. Документация обновлена (golden source)
- **PASS:** обновлены `deployer.md` (стадия deploy = вызов хука), `INFRA.md` (процедура),
`CHANGELOG.md`; заведён ADR в `06-adr/`.
- **FAIL:** функционал изменён, документация — нет (Reviewer обязан вернуть REQUEST_CHANGES).
- **Проверка:** диффы документации присутствуют в том же PR.
---
## Definition of Done
Все AC-1…AC-13 в статусе PASS; `pytest tests/` зелёный; артефакты pipeline на месте;
прод (8500) во время разработки НЕ тронут (вся проверка — на staging-цели хука).

View File

@@ -0,0 +1,122 @@
work_item: ORCH-036
title: "Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)"
stage: analysis
notes: >
Все тесты — на изолированном уровне (unit/integration с моками subprocess/ssh
и хука). Реальный прод (8500) НЕ трогается. Интеграционные прогоны хука — на
staging-цели. Хост-хук (bash) проверяется отдельным интеграционным сценарием с
поддельным health/exit-code; в pytest вызов хука мокается.
tests:
# --- exit-code -> deploy_status mapping (AC-1, AC-3) ---
- id: TC-01
type: unit
description: "Маппинг exit-code хука 0 -> deploy_status: SUCCESS"
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-02
type: unit
description: "Маппинг exit-code хука 1 (rolled back) -> deploy_status: FAILED"
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-03
type: unit
description: "Маппинг exit-code хука 2 (rollback тоже упал) -> deploy_status: FAILED"
module: tests/test_deploy_hook_mapping.py
expected: PASS
# --- approve gate (AC-5, AC-12) ---
- id: TC-04
type: unit
description: "DEPLOY_REQUIRE_MANUAL_APPROVE дефолт == true в settings"
module: tests/test_deploy_approve.py
expected: PASS
- id: TC-05
type: integration
description: "Флаг true и нет 'go' -> прод-хук НЕ вызывается (subprocess/ssh не дёрнут)"
module: tests/test_deploy_approve.py
expected: PASS
- id: TC-06
type: integration
description: "Флаг true и есть 'go' -> прод-хук вызывается ровно один раз"
module: tests/test_deploy_approve.py
expected: PASS
# --- self vs non-self routing (AC-2, AC-11) ---
- id: TC-07
type: unit
description: "is_self_hosting_repo('orchestrator') == True; иной репо -> False (не регрессировал)"
module: tests/test_deploy_routing.py
expected: PASS
- id: TC-08
type: integration
description: "self-репо orchestrator: рестарт инициируется detached/host-процессом, не синхронно из агента"
module: tests/test_deploy_routing.py
expected: PASS
- id: TC-09
type: integration
description: "не-self репо (enduro-trails): деплой идёт прежним ssh-путём, self-логика не применяется"
module: tests/test_deploy_routing.py
expected: PASS
# --- rollback on FAILED (AC-4) ---
- id: TC-10
type: integration
description: "deploy_status: FAILED -> откат deploy->development, set_issue_blocked, release merge-lease"
module: tests/test_deploy_rollback.py
expected: PASS
# --- staging precondition preserved (AC-8) ---
- id: TC-11
type: integration
description: "staging_status: FAILED -> до стадии deploy не доходит (откат на development)"
module: tests/test_staging_precondition.py
expected: PASS
# --- notifications (AC-6) ---
- id: TC-12
type: integration
description: "Успешный промоут -> и Plane-коммент, и Telegram отправлены"
module: tests/test_deploy_notifications.py
expected: PASS
- id: TC-13
type: integration
description: "Откат -> и Plane-коммент, и Telegram отправлены (нет молчаливого деплоя)"
module: tests/test_deploy_notifications.py
expected: PASS
# --- build-once (AC-7) ---
- id: TC-14
type: integration
description: "Прод-деплой использует образ staging (retag, без build) — нет шага docker build"
module: tests/test_deploy_build_once.py
expected: PASS
# --- regression: unchanged gate contracts (AC-10) ---
- id: TC-15
type: unit
description: "_parse_deploy_status: SUCCESS->(True), FAILED->(False), нет frontmatter->(False) — контракт цел"
module: tests/test_qg_checks.py
expected: PASS
- id: TC-16
type: unit
description: "STAGE_TRANSITIONS deploy->done и agent/qg deploy не изменены"
module: tests/test_stages.py
expected: PASS
- id: TC-17
type: integration
description: "terminal-sync deploy->done (set_issue_done + release merge-lease) сохранён"
module: tests/test_deploy_terminal_sync.py
expected: PASS
- id: TC-18
type: integration
description: "merge-gate на ребре deploy-staging->deploy не затронут (регресс ORCH-43 зелёный)"
module: tests/test_merge_gate.py
expected: PASS
# --- auto-rollback hook behavior (AC-9) ---
- id: TC-19
type: integration
description: "Симуляция битого деплоя на staging-цели: health fail -> авто-rollback -> healthy, exit 1, MTTR<60с"
module: tests/test_deploy_hook_rollback_sim.py
expected: PASS

View File

@@ -0,0 +1,184 @@
# ADR-001: Исполняемый самодеплой — стадия `deploy` дёргает хост-хук (Вариант B)
Work Item: ORCH-036
Stage: architecture
Автор: architect
Дата: 2026-06-06
## Статус
Accepted
## Контекст
Стадия `deploy` сейчас «бумажная»: deployer-агент (LLM) пишет в `14-deploy-log.md`
`deploy_status: SUCCESS|FAILED`, а гейт `check_deploy_status` (`src/qg/checks.py:464`)
парсит этот вердикт и двигает `deploy → done`. Реального docker-деплоя нет (прод
катается руками). BRD ORCH-36 требует превратить стадию в РЕАЛЬНЫЙ самодеплой с
обязательным ручным approve, build-once и авто-rollback (BR-1…BR-10).
Три твёрдых ограничения, разведанных в коде, определяют дизайн:
1. **Self-restart (BR-2).** Прод-контейнер `orchestrator` (8500) — ОДИН на все
проекты, и в нём же исполняется deployer. `docker compose up -d orchestrator`
из контейнера убьёт процесс агента/воркера на середине. Реальный рестарт обязан
делать ВНЕШНИЙ процесс на хосте, переживающий гибель контейнера.
2. **Status-only verdict model.** Комментарии Plane НЕ управляют конвейером —
механизм `:approved:`/`:rejected:` был удалён (`src/webhooks/plane.py:544`,
bug-3 «echo self-hit»). Единственный человеческий гейт — **смена статуса Plane
на `Approved`** (`handle_verdict``_try_advance_stage``advance_stage`).
3. **Гонка чтения гейта.** Так как реальный рестарт асинхронный и убивает контейнер,
`check_deploy_status` нельзя выполнять на выходе агента — вердикта ещё нет; его
преждевременное чтение → ложный FAILED → ложный откат.
Контракты, которые НЕ меняются (BR-9, AC-10): `STAGE_TRANSITIONS`,
`check_deploy_status` / `_parse_deploy_status` (frontmatter only), откат БАГ-8
(`deploy → development`), terminal-sync `deploy → done`, merge-gate (ORCH-43),
exit-code-контракт хука (0/1/2).
## Решение
Деплой стадии `deploy` для self-hosting (`orchestrator`) разбивается на **три фазы**,
оркеструемые детерминированным кодом (без LLM в критическом пути self-restart). Для
НЕ-self репо (enduro-trails и пр.) поведение НЕ меняется — прежний синхронный
ssh-деплой агентом.
### Условность по репо
Вся новая логика гейтится `is_self_hosting_repo(repo)` (как ORCH-35). Не-self репо
идут существующим путём: deployer-агент на стадии `deploy` делает ssh-деплой
синхронно, пишет `14-deploy-log.md`, гейт срабатывает на выходе агента.
### Фаза A — запрос approve (вход в `deploy`)
В `advance_stage` на ребре `deploy-staging → deploy` (ПОСЛЕ зелёного
`check_staging_status` и merge-gate ORCH-43), для self-hosting + `deploy_require_
manual_approve=true`:
- **НЕ** ставить в очередь прод-deployer (перехватить штатный
`enqueue_job(get_agent_for_stage("deploy-staging"))`);
- выставить issue в approval-pending статус (паттерн `set_issue_in_review`),
написать Plane-коммент «approve для прод-деплоя» + Telegram (BR-5);
- записать restart-safe маркер `approve-requested` (sentinel-файл, см. ниже).
Задача остаётся на стадии `deploy` и ждёт человека. `STAGE_TRANSITIONS` не меняется.
При `deploy_require_manual_approve=false` (вне объёма, флаг НЕ выключается в ORCH-36 —
AC-12) Фаза A сразу переходит к Фазе B без человеческого гейта. Структурная ветка
закладывается, но дефолт `true`.
### Фаза B — инициация деплоя (смена статуса Plane → Approved)
Человек ставит issue в `Approved`. `handle_verdict(approved=True)`
`_try_advance_stage``advance_stage(current_stage="deploy", finished_agent=None)`.
Новая ветка-перехват в `advance_stage`:
- условие: `current_stage=="deploy"` И `finished_agent is None` (человеческий путь)
И self-hosting И approve-флаг И маркер `initiated` ОТСУТСТВУЕТ;
- действие: запустить **внешний detached host-процесс** (см. ниже) и поставить в
очередь детерминированный **finalizer-job** с задержкой; записать маркер
`initiated` (идемпотентность: повторный Approved не запускает деплой дважды);
Plane-коммент «прод-деплой стартовал» + Telegram (BR-5);
- **вернуться БЕЗ advance** (НЕ запускать `check_deploy_status` — вердикта ещё нет).
Дискриминатор `finished_agent` разводит Фазу B (человек, `None`) и Фазу C
(finalizer, `"deployer"`), поэтому повторное использование `advance_stage` безопасно.
### Фаза C — фиксация вердикта (детерминированный finalizer)
Finalizer-job (claim'ится воркером уже в НОВОМ контейнере после рестарта):
- читает sentinel `result` (exit-code хука, записан host-процессом);
- если `result` ещё нет и бюджет попыток не исчерпан → **defer** (повторный
finalizer-job с `available_at_delay_s`, как merge-gate defer); бюджет считается
из `jobs` (`LIKE '%deploy-finalize%'`, restart-safe);
- если `result` есть → **маппинг exit-code → deploy_status** (детерминированный,
unit-тестируемый): `0 → SUCCESS`, `1|2|иное → FAILED`; записать
`14-deploy-log.md` (frontmatter `deploy_status:`), смержить в `main` (паттерн
лога), затем вызвать `advance_stage(current_stage="deploy", finished_agent="deployer")`;
- далее срабатывают СУЩЕСТВУЮЩИЕ контракты: `SUCCESS` → terminal-sync `deploy → done`
+ release merge-lease; `FAILED` → откат БАГ-8 `deploy → development` +
`set_issue_blocked` + Plane/Telegram (BR-3, AC-4). `_parse_deploy_status` НЕ меняется.
### Механизм detached-запуска: ssh + setsid
Выбор: **ssh на хост (`slin@DEPLOY_SSH_HOST`) с setsid-detached исполнением** хука.
Обоснование: ssh-ключи уже смонтированы (INFRA P-2), не-self репо уже деплоятся по
ssh (единый путь), хук живёт на хосте и под `slin` имеет полный доступ к docker вне
контейнера → переживает рестарт 8500 (BR-2). `setsid`/`nohup` + redirect отвязывает
удалённый процесс от ssh-канала, чтобы он пережил гибель ssh-клиента при рестарте
контейнера. Отвергнуто: вызов через docker.sock изнутри контейнера = ровно мина
«убей себя на середине вызова».
Эскиз (точная сборка — за разработчиком):
```
ssh -o StrictHostKeyChecking=no slin@$DEPLOY_SSH_HOST \
"setsid bash -c 'cd /home/slin/repos/orchestrator && \
SOURCE_IMAGE=orchestrator-orchestrator-staging \
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE= \
PREV_IMAGE_FILE=.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy; \
echo \$? > <result-sentinel>' >> <hook.log> 2>&1 </dev/null &"
```
ssh-команда возвращается сразу; remote-процесс detached. Запись sentinel `result`
делает **обёртка** (`echo $? > result`), а НЕ хук — контракт хука нетронут.
### Build-once (BR-6, AC-7)
Прод обязан подняться на ОБРАЗЕ, прошедшем staging (а не на пересборке). Решение:
расширить хук **опциональным** `SOURCE_IMAGE` (обратно совместимо: не задан →
текущее поведение). При заданном `SOURCE_IMAGE` хук ПЕРЕД `up -d --no-build`
делает `docker tag $SOURCE_IMAGE $TARGET_IMAGE`. Для прод-self:
`SOURCE_IMAGE=orchestrator-orchestrator-staging``TARGET_IMAGE=orchestrator-orchestrator`.
Это единственное допустимое изменение хука; exit-code-контракт и дефолтное
staging-поведение не меняются. `git pull` хука обновляет рабочее дерево хоста для
будущих сборок, но РАЗВЁРНУТЫЙ артефакт = перетегированный staging-образ.
### Restart-safe состояние: sentinel-файлы (без миграции БД)
По образцу merge-lease (`<repos_dir>/.merge-lease-<repo>.json`) состояние деплоя
хранится в файлах под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (вне git,
видны и хосту, и контейнеру через mount `/home/slin/repos ↔ /repos`):
- `approve-requested` — Фаза A выполнена;
- `initiated` — Фаза B запущена (idempotency-guard);
- `result` — exit-code хука (пишет host-обёртка).
Бюджет finalize-defer считается из `jobs` (restart-safe), новых таблиц/колонок НЕТ
(TRZ §4).
## Последствия
### Плюсы
- `deploy_status: SUCCESS` становится ДОКАЗАННЫМ (реальный health-ok хука), не
декларацией LLM (BR-1).
- Self-restart безопасен: рестарт 8500 делает внешний host-процесс; орк себя не
убивает (BR-2). Вердикт фиксирует НОВЫЙ контейнер после рестарта.
- Критический путь self-restart **детерминирован** (без LLM) — главный выигрыш по
безопасности self-hosting; зеркалит детерминизм merge-gate ORCH-43.
- Approve вписан в существующую status-only модель — restart-safe, аудируемо в Plane,
идемпотентно (маркер `initiated`).
- Гонка чтения гейта закрыта: гейт читает РЕАЛЬНЫЙ итог через finalizer-defer.
- Build-once гарантирует «что тестировали — то в проде».
- Нетронуты: `STAGE_TRANSITIONS`, реестр QG, `_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, контракт хука (exit-code).
### Минусы / ограничения
- Вводится **новый детерминированный job-handler** в очереди (reserved-agent
`deploy-finalizer`, не-LLM) — расширение dispatch воркера/лаунчера. Контейнированное,
но это новая под-компонента → задача помечается `arch:major-change`.
- Перехваты в `advance_stage` усложняют стадию `deploy` (три ветки по
`finished_agent`/маркерам). Требуется аккуратное покрытие тестами (TC-04…TC-09).
- Build-once зависит от того, что deploy-staging оставил валидный образ
`orchestrator-orchestrator-staging`; при rebase merge-gate возможен дрейф
образ↔main (см. 10-tech-risks R-3).
- Approve = смена статуса Plane на `Approved`; человек должен понимать, что на
стадии `deploy` `Approved` означает «деплой в прод» (документируется в deployer.md
и INFRA.md).
### Что обязан сделать developer
1. `src/config.py`: `deploy_require_manual_approve: bool = True` + прод-параметры
хука/ssh + `deploy_finalize_delay_s` / `deploy_finalize_max_attempts`.
2. `src/stage_engine.py`: перехваты Фазы A/B + ветка finalizer (Фаза C через
`advance_stage(..., finished_agent="deployer")`).
3. Очередь: reserved-agent `deploy-finalizer` (детерминированный handler:
read-result | defer | map+write+advance). Маппинг exit→status — отдельная
чистая функция (unit TC-01/02/03).
4. `scripts/orchestrator-deploy-hook.sh`: опциональный `SOURCE_IMAGE` retag
(обратно совместимо) + прод `PREV_IMAGE_FILE`.
5. Уведомления (Plane+Telegram) на initiate/success/rollback (BR-5).
6. Документация: `deployer.md`, `INFRA.md`, `DEPLOY_HOOK.md`, `CHANGELOG.md`.
7. Отладка — только на staging-цели хука; прод 8500 в разработке не трогать.
## Связанные решения
- Глобальный ADR: `docs/architecture/adr/adr-0007-executable-self-deploy.md`.
- ORCH-35 staging-gate (`adr-0003`), ORCH-43 merge-gate (`adr-0006`),
ORCH-21 auto-rollback, ORCH-34 хук, ORCH-40 run-as-host-uid (`adr-0005`).

View File

@@ -0,0 +1,48 @@
# Инфраструктурные требования — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
> Топология не меняется (та же mva154, те же два контейнера). Меняется ПРОЦЕДУРА
> прод-деплоя орка: из ручной → исполняемая через хост-хук с ручным approve.
## 1. Контейнеры / порты — без изменений
- prod `orchestrator` (8500), staging `orchestrator-staging` (8501) — как в INFRA.md.
- Образы (имена для build-once): prod `orchestrator-orchestrator`,
staging `orchestrator-orchestrator-staging`.
## 2. Хост-предусловия (Owner, в git не коммитятся)
- **HP-1.** ssh-доступ из контейнера на хост: `ssh slin@$DEPLOY_SSH_HOST` работает
под uid 1000 ключом из `~/.orchestrator-ssh` (INFRA P-2). Без него detached-запуск
Фазы B невозможен.
- **HP-2.** `<repos_dir>/.deploy-state-<repo>/` доступен на запись и хосту (host-обёртка
пишет `result`), и контейнеру (finalizer читает) — обеспечивается mount
`/home/slin/repos ↔ /repos` (как merge-lease).
- **HP-3.** `PREV_IMAGE_FILE` для прод — отдельный путь
(`.deploy-prev-image-prod`), чтобы не путать снапшоты prod/staging.
- **HP-4 (P-4 из INFRA).** Прод-рестарт self — только в окно тишины; общий инстанс
с enduro-trails. На старте — под ручным approve (флаг `true`).
## 3. Переменные окружения (карта; значения — на хосте, в git только дескрипторы)
| Переменная | Назначение | Дефолт |
|-----------|-----------|--------|
| `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` | ручной approve перед прод-деплоем | `true` |
| `DEPLOY_SSH_USER` / `DEPLOY_SSH_HOST` | ssh-цель хост-хука | — (INFRA-карта) |
| `DEPLOY_HOOK_SCRIPT` | путь к хуку на хосте | `scripts/orchestrator-deploy-hook.sh` |
| прод `TARGET_SERVICE/PORT/IMAGE`, `COMPOSE_PROFILE` | override прод-профиля хука | `orchestrator`/`8500`/`orchestrator-orchestrator`/пусто |
| `SOURCE_IMAGE` (новый параметр хука) | образ для build-once retag | пусто → текущее поведение |
| `ORCH_DEPLOY_FINALIZE_DELAY_S` | задержка перед первым finalize-поллом | > 60с (health-loop хука) |
| `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS` | бюджет finalize-defer | bounded (anti-livelock) |
Прописать дескрипторы в `.env.example` / INFRA.md. Реальные значения не коммитить.
## 4. Сетевые / процессные требования
- Detached host-процесс (ssh + setsid) обязан пережить рестарт прод-контейнера 8500.
- Finalizer-job исполняется в НОВОМ контейнере после рестарта (очередь restart-safe).
- MTTR авто-rollback < 60с (health-loop хука 10×6с уже укладывается, BR-8/AC-9).
## 5. Что НЕ требуется
- Новых контейнеров/портов/сервисов — нет.
- Изменений `docker-compose.yml` — не требуется (build-once через retag, не профиль).
- Multi-node / облако / message-queue — нет (принципы проекта).

View File

@@ -0,0 +1,34 @@
# Требования к данным / схеме БД — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
## Решение: миграция БД НЕ требуется
Схема SQLite (`events`, `tasks`, `agent_runs`, `jobs`) не меняется. Обоснование:
1. **Вердикт деплоя** — в `14-deploy-log.md` (frontmatter `deploy_status:`), как
сейчас. `_parse_deploy_status` не трогаем (AC-10).
2. **Approve / initiated / result-состояние** — restart-safe через **sentinel-файлы**
под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (паттерн merge-lease
`<repos_dir>/.merge-lease-<repo>.json`), а не через новую таблицу/колонку:
- `approve-requested` — Фаза A;
- `initiated` — Фаза B (idempotency-guard);
- `result` — exit-code хука (пишет host-обёртка).
3. **Бюджет finalize-defer** считается из существующей таблицы `jobs`
(`task_content LIKE '%deploy-finalize%'`), как `_merge_defer_count` для merge-gate
— restart-safe, без новых полей.
4. **Finalizer-job** использует существующую структуру `jobs` (agent, repo,
task_content, task_id, available_at). Reserved-agent `deploy-finalizer` — это
значение в колонке `agent`, схема не меняется.
## Почему файлы, а не БД
- Sentinel должен быть виден И хосту (пишет `result`), И контейнеру (читает finalizer);
файл на общем mount это обеспечивает, SQLite-запись из host-обёртки — нет.
- Зеркалит уже принятый паттерн merge-lease (ORCH-43) — единообразие, restart-safe,
crash-реклейм по возрасту файла.
Если разработчик при реализации сочтёт необходимым поле статуса approve в БД —
это требует обновления данного ADR с обоснованием; по умолчанию — без миграции
(согласовано с TRZ §4).

View File

@@ -0,0 +1,23 @@
# Технические риски — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
| ID | Риск | Влияние | Вероятность | Митигация |
|----|------|---------|-------------|-----------|
| R-1 | Detached host-процесс не пережил рестарт 8500 (ssh-канал убит вместе с контейнером) | Деплой не завершён, `result` не записан, finalizer вечно defer'ит | Средняя | `setsid`/`nohup` + redirect отвязывает remote-процесс от ssh; интеграционная проверка на staging-цели (TC-08); finalize-defer bounded → по исчерпании `set_issue_blocked` + Telegram |
| R-2 | Преждевременное чтение `check_deploy_status` (вердикта ещё нет) | Ложный FAILED → ложный откат на development | Средняя | Фаза B возвращается БЕЗ advance; гейт запускает только finalizer (Фаза C) после появления `result`; defer пока `result` отсутствует |
| R-3 | Дрейф образ↔main: merge-gate сделал rebase, но staging-образ собран до rebase → build-once тегирует «не тот» код | В прод уезжает не точно то, что в `main` | Низкая | merge-gate (ORCH-43) делает re-test после rebase; build-once = «что валидировано на staging», что и есть контракт; задокументировано как осознанное ограничение; усиление (rebuild+revalidate staging после rebase) — отдельная задача |
| R-4 | Двойной Approved (человек кликнул дважды / дубль webhook) запускает деплой дважды | Двойной рестарт прода, гонка | Средняя | Маркер `initiated` (idempotency-guard); event-dedup webhook'ов Plane уже есть |
| R-5 | exit 2 хука (rollback тоже упал) → 8500 лежит → finalizer/новый контейнер не поднялся | Конвейер всех проектов встал | Низкая | health-loop + авто-rollback хука минимизируют; `restart: unless-stopped` поднимет контейнер на ПРЕДЫДУЩЕМ образе если retag не случился; exit 2 → `deploy_status: FAILED` + откат + Telegram-алерт; ручной `--rollback` хука как backstop |
| R-6 | Reserved-agent `deploy-finalizer` ошибочно уйдёт в LLM-путь лаунчера (`_spawn` → ValueError) | Finalizer не отработает | Низкая | Перехват ДО `_spawn` в `launch_job`; unit-тест маршрутизации |
| R-7 | sentinel-файлы не видны контейнеру/хосту (mount/uid) | Фазы B/C не синхронизируются | Низкая | Тот же mount и uid-модель, что у merge-lease (ORCH-40/43); HP-2 в 07-infra |
| R-8 | Approve через смену статуса Plane конфликтует с auto-advance других стадий | Случайный `Approved` на `deploy` ничего не ломает, но семантика неочевидна | Низкая | Перехват по `current_stage=="deploy"` + `finished_agent is None` + маркеры; задокументировать в deployer.md/INFRA, что `Approved` на `deploy` = «деплой в прод» |
| R-9 | Самодеплой ORCH ломает прод во время разработки самой ORCH-36 | Групповой простой (enduro-trails) | Низкая | Вся отладка — на staging-цели хука (8501); прод 8500 не трогать (AC: DoD); флаг approve=true |
## Сводный приоритет
- **Блокеры дизайна:** R-1, R-2 — закрыты архитектурой (setsid-detached + finalizer-defer).
- **Безопасность self-hosting:** R-5, R-9 — закрыты обязательным approve + staging-отладкой
+ авто-rollback + `restart: unless-stopped`.
- **Корректность:** R-3, R-4 — осознанные ограничения / idempotency-guard.

View File

@@ -0,0 +1,64 @@
---
type: review
work_item_id: ORCH-036
verdict: APPROVED
version: 2
---
# Review ORCH-036 — Исполняемый самодеплой стадии `deploy` (Вариант B)
## Summary
Re-review после фикса двух P1 из версии 1. Оба блокера устранены:
1. **Stale deploy-state маркеры** — добавлен `self_deploy.clear_state(repo, work_item_id)`
(never-raise, idempotent, рекурсивное удаление `<repos_dir>/.deploy-state-<repo>/<wi>/`)
в ветке БАГ-8-отката `check_deploy_status` FAILED (`_handle_qg_failure_rollbacks`,
`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`)
как belt-and-suspenders. Добавлен регрессионный тест
`tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`,
доказывающий, что после FAILED → откат → фикс → повторный заход на `deploy` Фаза B
РЕАЛЬНО инициирует деплой (нет no-op по устаревшему `initiated`), плюс
`tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`.
2. **`.env.example`** — добавлен полный блок дескрипторов `ORCH_SELF_DEPLOY_*` /
`ORCH_DEPLOY_*` (14 настроек, плейсхолдеры, секреты не коммитятся) по образцу
merge-gate ORCH-043, с подробными комментариями.
Реализация трёхфазного исполняемого самодеплоя соответствует ADR-001 и закрывает
критерии приёмки AC-1…AC-13. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` /
`_parse_deploy_status` / БАГ-8 / terminal-sync / merge-gate (ORCH-43) НЕ тронуты;
условность по репо (`self_deploy_applies`) корректна; перехваты упорядочены верно
(Phase B после terminal-check, Phase A после merge-gate); `deploy-finalizer`
детерминированный no-LLM reserved-agent, перехвачен в launcher до `_spawn`. Все
импорты (`set_issue_in_review`, `plane_add_comment`, `set_issue_blocked`,
`send_telegram`) присутствуют. `pytest tests/`**568 passed**.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет — оба P1 из версии 1 устранены и покрыты тестами)
### P2 — Should fix
- (нет блокирующих; прежний P2 про сквозную процедуру оператора частично закрыт:
env-карта новых настроек добавлена в INFRA.md, пошаговый approve→deploy описан в
deployer.md и DEPLOY_HOOK.md)
## Документация
Обновлена содержательно и в том же PR:
- `.openclaw/agents/deployer.md` — стадия `deploy` переписана: self-hosting путь
(Фазы A/B/C, явный запрет рестарта 8500 изнутри агента) vs прежний синхронный
ssh-путь для не-self репо;
- `docs/operations/INFRA.md` — env-карта всех новых `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*`;
- `docs/operations/DEPLOY_HOOK.md``SOURCE_IMAGE` build-once + прод-пример;
- `docs/architecture/README.md` — раздел «Исполняемый самодеплой стадии `deploy`»;
- `CHANGELOG.md` — запись Added (фича) + запись Fixed (review-fix: clear_state + .env.example);
- ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md` + глобальный
`docs/architecture/adr/adr-0007-executable-self-deploy.md`;
- **`.env.example`** — канонический шаблон (CLAUDE.md №8, ТЗ §2.6) дополнен (был пробел в v1).
Документация = golden source: изменения `src/` сопровождены синхронным обновлением
доки в том же PR. Ось документации — PASS.

View File

@@ -0,0 +1,90 @@
---
type: test-report
work_item_id: ORCH-036
result: PASS
---
# Test Report — ORCH-036
Исполняемый самодеплой стадии `deploy` (Вариант B) — дёргает хост-хук
`scripts/orchestrator-deploy-hook.sh`, три фазы (A/B/C), условность по self-hosting репо.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (pluggy 1.6.0, anyio 4.13.0, asyncio 0.23.8 — mode AUTO)
- Worktree: `feature/ORCH-036-orch-36-deploy-b`
- Дата: 2026-06-06
- Prod (8500) во время тестов НЕ тронут: вся проверка изолированная (моки subprocess/ssh/хука).
Smoke выполнялся read-only GET-запросами.
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| GET /health | `{"status":"ok","service":"orchestrator"}` — OK |
| GET /status | OK (отдаёт активные задачи) |
| GET /queue | OK (counts/max_concurrency/resilience; breaker=closed, preflight_ok=true) |
`curl` в окружении отсутствует — smoke выполнен через `urllib.request` (эквивалент GET).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | exit 0 → deploy_status: SUCCESS | test_tc01_exit0_maps_to_success | PASS |
| TC-02 | exit 1 (rolled back) → FAILED | test_tc02_exit1_rolled_back_maps_to_failed | PASS |
| TC-03 | exit 2 (rollback тоже упал) → FAILED | test_tc03_exit2_rollback_also_failed_maps_to_failed | PASS |
| TC-04 | DEPLOY_REQUIRE_MANUAL_APPROVE дефолт == true | test_tc04_manual_approve_default_true | PASS |
| TC-05 | true и нет approve → прод-хук НЕ вызван | test_tc05_no_approve_does_not_call_prod_hook | PASS |
| TC-06 | true и approve → прод-хук вызван ровно 1 раз | test_tc06_approved_calls_prod_hook_exactly_once | PASS |
| TC-07 | is_self_hosting_repo: только orchestrator True | test_tc07_is_self_hosting_repo_only_orchestrator | PASS |
| TC-08 | self-репо: рестарт detached host-процессом | test_tc08_self_repo_launches_detached_host_process | PASS |
| TC-09 | не-self репо: прежний ssh-путь | test_tc09_non_self_repo_uses_legacy_path | PASS |
| TC-10 | FAILED → откат deploy→development, blocked, release lease | test_tc10_failed_deploy_rolls_back_to_development | PASS |
| TC-11 | staging_status FAILED → до deploy не доходит | test_tc11_staging_failed_never_reaches_deploy | PASS |
| TC-12 | успех → Plane-коммент + Telegram | test_tc12_success_notifies_plane_and_telegram | PASS |
| TC-13 | откат → Plane-коммент + Telegram | test_tc13_rollback_notifies_plane_and_telegram | PASS |
| TC-14 | build-once: retag staging-образа, без build | test_tc14_deploy_command_retags_staging_image_no_build | PASS |
| TC-15 | _parse_deploy_status контракт цел (проза не проходит) | test_qg_checks::test_tc15_* (5 кейсов) | PASS |
| TC-16 | STAGE_TRANSITIONS deploy/deploy-staging не изменены | test_stages::test_tc16_* | PASS |
| TC-17 | terminal-sync deploy→done сохранён | test_tc17_success_deploy_syncs_terminal_done | PASS |
| TC-18 | merge-gate (ORCH-43) на ребре не затронут | test_merge_gate (14 кейсов) | PASS |
| TC-19 | симуляция битого деплоя: авто-rollback → healthy, exit 1 | test_tc19_unhealthy_deploy_auto_rolls_back_exit1 | PASS |
Доп. регрессионные тесты (review-fix): `test_clear_state_removes_all_markers_and_is_idempotent`,
`test_tc11_re_deploy_after_rollback_not_wedged`оба PASS (stale deploy-state очищается, повторный
заход на deploy после отката не зависает).
## Покрытие критериев приёмки
| AC | Покрыт тестами | Статус |
|----|----------------|--------|
| AC-1 реальный деплой (не бумажный) | TC-01..03, TC-14, TC-19 | PASS |
| AC-2 self-репо рестарт detached, агент себя не убивает | TC-08 | PASS |
| AC-3 deploy_status из exit-code | TC-01..03 | PASS |
| AC-4 FAILED → откат на development | TC-10 | PASS |
| AC-5 ручной approve реально тормозит прод | TC-05, TC-06 | PASS |
| AC-6 уведомления о промоуте и откате | TC-12, TC-13 | PASS |
| AC-7 build-once (образ из staging) | TC-14 | PASS |
| AC-8 staging-гейт обязателен | TC-11 | PASS |
| AC-9 авто-rollback восстанавливает прод (MTTR<60с) | TC-19 | PASS |
| AC-10 инварианты не сломаны | TC-15..18 + полный регресс | PASS |
| AC-11 условность по репо (не-self не ломается) | TC-07, TC-09 | PASS |
| AC-12 флаг авто НЕ выключен (остаётся true) | TC-04 | PASS |
| AC-13 документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS |
## Вывод pytest
Полный регресс:
```
======================= 568 passed, 1 warning in 15.25s ========================
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, не связан с задачей)
Целевые модули тест-плана:
```
======================== 46 passed, 1 warning in 2.17s =========================
```
## Итог
**PASS** — все 19 TC зелёные, все критерии приёмки AC-1…AC-13 покрыты, полный регресс
568/568 passed, smoke API OK, прод (8500) не тронут. Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,39 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T21:06:37Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
Executed canonically inside the container (ORCH-048, ADR-001):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
(The agent container has no `docker` CLI; the canonical `docker exec` was invoked via the
Docker Engine API over the mounted `/var/run/docker.sock`, which is equivalent — the command
ran inside `orchestrator-staging` so the B6 registry-isolation check read the staging
process-env `.env.staging`.)
**Result: 10/10 checks PASS — exit code 0.**
| Block | Check | Verdict |
|-------|-------|---------|
| A SMOKE | A1 `GET /health` → 200 status=ok | PASS |
| A SMOKE | A2 `GET /queue` → 200 (counts/max_concurrency/resilience) | PASS |
| A SMOKE | A3 `ORCH_STAGING=true` (not prod) | PASS |
| B ACCESS | B4 Plane sandbox project accessible | PASS |
| B ACCESS | B5 Gitea `orchestrator-sandbox` accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via `/webhook/plane` | PASS |
| C E2E | C9a Branch appears in `orchestrator-sandbox` | PASS |
| C E2E | C9b Analyst job enqueued in staging queue | PASS |
CLEANUP: test branch deleted, Plane SANDBOX issue deleted, staging DB job/task rows removed
(`try/finally` guaranteed). No prod (8500) container was touched.

View File

@@ -0,0 +1,7 @@
# Business Request: Агенты пишут файлы под root в смонтированный хост-репо: ломает git/ребилд
Work Item ID: ORCH-040
## Description
TBD

View File

@@ -0,0 +1,106 @@
# 01 — BRD: Агенты пишут файлы под root в смонтированный хост-репо
Work Item: **ORCH-040**
Тип: инфра-фикс (runtime / docker-compose)
Исполнение: через Dev напрямую (по решению Owner)
## 1. Бизнес-контекст и проблема
Контейнер `orchestrator` (prod, 8500) работает под `uid=0 (root)`. Он монтирует
хостовый каталог `/home/slin/repos``/repos` (rw). Claude-CLI агенты запускаются
через `subprocess.Popen` **внутри контейнера**, то есть тоже под root. Они пишут:
- в git worktree задач — `/repos/_wt/<repo>/<branch>/...`;
- в прод-клон — `/repos/<repo>/docs/work-items/...` (через коммит/пуш из worktree).
В результате на **хосте** файлы создаются с владельцем `root:root`.
### Симптом
При ребилде/деплое прода `git pull` / `git reset` под пользователем `slin` падает:
```
error: insufficient permission for adding an object to repository database .git/objects
Permission denied (на docs/work-items/ORCH-016, владелец root:root)
```
Каждый будущий деплой будет ломаться, пока вручную не выполнить `chown`.
### Диагноз (живая разведка 0506.06)
- `docker exec orchestrator id``uid=0(root) gid=0(root) groups=0,999`.
- Хост `slin` = `uid=1000 gid=1000`, группы: `sudo`, `docker(999)`.
- `/home/slin/repos``/repos` (rw); на хосте `/repos` уже `1000:1000 rwxrwxr-x`.
- `docs/work-items/*` на хосте — `root:root` (наследие прошлых прогонов).
## 2. Цель
Агенты конвейера **не должны** создавать `root`-файлы в хостовом репозитории.
После любого прогона конвейера `git pull/status/reset` под `slin` на хосте
работает **без ручного chown**.
## 3. Объём (scope)
В объёме:
- Изменение runtime-режима контейнера так, чтобы артефакты создавались под
`uid:gid` хоста (`1000:1000`).
- Сохранение работоспособности: claude-auth (preflight), git/ssh, docker.sock
(деплой), запуск конвейера.
- Обновление документации (INFRA.md, CHANGELOG, ADR с обоснованием варианта).
- Проверка на staging (8501) ДО прода.
Вне объёма:
- Массовое исправление прав уже существующих `root:root` файлов в истории
(разовый `chown` на хосте делает Owner; в задаче — только описать команду).
- Изменение логики конвейера, QG, схемы БД.
- Смена модели/effort агентов, прочие фичи.
## 4. Заинтересованные стороны
- Owner (Слава) — заказчик, владелец хоста mva154.
- Стрим — разведка/контекст.
- Проект enduro-trails — co-tenant того же прод-инстанса (групповой риск).
## 5. Ограничения и риски (off-limits)
Self-hosting: прод-инстанс `orchestrator` ОДИН на все прод-проекты, общая БД и
очередь. **Нельзя ломать**: запуск конвейера, доступ к Plane/Gitea/SSH из агентов,
docker.sock. Любой рестарт контейнера под новым uid — **только в окно тишины**
(нет активных задач). Тестировать на staging ПЕРЕД продом.
### Известные мины (подтверждены разведкой)
- **МИНА 1 — docker.sock**: `/var/run/docker.sock` = `srw-rw---- root:999`.
Доступ идёт через gid 999, не через root. При переходе на непривилегированный
uid обязателен supplementary group `999`. *В текущем `docker-compose.yml` уже
есть `group_add: ["999"]` для обоих сервисов — учесть, не сломать.*
- **МИНА 2 — claude creds (БЛОКЕР)**: `/home/slin/.claude/.credentials.json` =
`root:root 0600`. Сейчас читает контейнер-root. Под `uid=1000` без доступа →
`claude-auth` ломается → весь конвейер умирает (preflight ORCH-044 заворачивает).
Проверить ПЕРВЫМ.
- **МИНА 3 — claude бинарь**: реальный бинарь `/opt/claude-code/bin/claude.exe`
(root:root, `+x` для всех — ok). `ORCH_CLAUDE_BIN=/usr/bin/claude` в env не
существует; launcher использует hardcode `CLAUDE_BIN=/opt/claude-code/bin/claude.exe`.
Под uid 1000 исполним, но проверить запуск.
- **SSH-маунт**: `/home/slin/.orchestrator-ssh``/root/.ssh:ro`. При смене uid
HOME/домашний каталог меняется — путь к ключам нужно поправить (деплой по ssh).
- **HOME**: launcher форсит `HOME=/home/slin` (две точки: env Popen и git_env).
Креды читаются из `/home/slin/.claude`. Учесть при смене uid.
## 6. Бизнес-ценность
Устранение постоянного ручного `chown` после каждого деплоя; деплой прода
перестаёт ломаться на правах; снимается источник простоя конвейера всех проектов.
## 7. Допущения
- Хост-каталоги `/app/data` и `/repos` уже `1000:1000` (запись под uid 1000 пройдёт).
- Dockerfile уже содержит `git config --system --add safe.directory '*'`.
- Окно тишины для рестарта контейнера согласуется с Owner.
## 8. Host-prerequisites (предусловия на стороне Owner)
Часть фикса невозможно закрыть только кодом — есть действия на хосте mva154,
которые выполняет Owner (в гит не коммитятся, фиксируются в ADR/INFRA). Это
обязательные предусловия Варианта 1; без них переход на uid 1000 ломает конвейер:
- **P-1 (блокер, МИНА 2):** обеспечить чтение `/home/slin/.claude/.credentials.json`
под uid 1000 (рекомендация — `chown -R 1000:1000 /home/slin/.claude`). Способ
выбирает ADR; анализ фиксирует факт предусловия.
- **P-2:** ssh-ключи (`/home/slin/.orchestrator-ssh`) читаемы uid 1000.
- **P-3:** подтверждение `slin = uid 1000 gid 1000` (подтверждено разведкой).
- **P-4:** рестарт прод-self только в окно тишины (`GET /status` без активных задач).
Детализация и команды — в `02-trz.md` §10.

View File

@@ -0,0 +1,112 @@
# 02 — ТЗ: agent-файлы под uid хоста (не root)
Work Item: **ORCH-040**
## 1. Суть требования
Артефакты конвейера (worktree + docs) должны создаваться на хосте под
`uid:gid = 1000:1000` (slin), а не `root:root`. При этом сохраняется работа
claude-auth, git, ssh-деплоя и docker.sock.
## 2. Задействованные модули и файлы
| Файл | Роль в задаче |
|------|----------------|
| `docker-compose.yml` | runtime-режим контейнера (prod `orchestrator` + `orchestrator-staging`). Основная точка изменения. |
| `Dockerfile` | возможные правки под непривилегированный запуск (safe.directory уже есть; при необходимости — создание пользователя/прав). |
| `src/agents/launcher.py` | `HOME=/home/slin` хардкод (env Popen ~стр.326 и git_env ~стр.513); путь `CLAUDE_BIN` (стр.187). Проверить совместимость при смене uid; править ТОЛЬКО при необходимости. |
| `docs/operations/INFRA.md` | блок «Тома (volumes)» (SSH-маунт `/root/.ssh`), карта рантайма — обновить. |
| `CHANGELOG.md` | запись об изменении. |
| `docs/work-items/ORCH-040/06-adr/` | ADR с выбором варианта + обоснованием (создаёт архитектор). |
## 3. Варианты решения (вход для ADR — выбор и обоснование за архитектором)
> Анализ фиксирует варианты как требование «выбрать и обосновать в ADR».
> Рекомендация разведки — Вариант 1.
1. **Вариант 1 (рекомендован): `user: "1000:1000"` в docker-compose.**
Все файлы сразу `slin:slin`, git на хосте без chown. Обязательные довески:
- сохранить/проверить `group_add: ["999"]` (docker.sock) — **уже присутствует**;
- обеспечить доступ uid 1000 к claude creds (`/home/slin/.claude/.credentials.json`):
`chown 1000:1000` на хосте ИЛИ права на чтение для 1000 (задокументировать);
- поправить SSH-маунт: `/home/slin/.orchestrator-ssh` → домашний каталог uid 1000
(`/home/slin/.ssh`), а не `/root/.ssh`; согласовать с `HOME` в launcher;
- проверить запуск `claude.exe` + `git` + `ssh` под uid 1000.
2. **Вариант 2: subprocess агента под непривилегированным uid внутри контейнера**
(`Popen preexec_fn setuid` / `gosu`). Точечно, но сложнее; контейнер остаётся root.
3. **Вариант 3 (fallback, костыль): chown-хук нормализации прав после стадии**
(`chown -R 1000:1000` worktree/доки). Лечит симптом, не корень. Применять, только
если В1 неустранимо рвёт creds/sock.
## 4. Требуемые изменения (при выбранном Варианте 1)
### 4.1 docker-compose.yml (оба сервиса: `orchestrator`, `orchestrator-staging`)
- Добавить `user: "1000:1000"`.
- Сохранить `group_add: ["999"]` (НЕ удалять).
- Изменить SSH-маунт: target `/root/.ssh` → каталог `.ssh` пользователя 1000,
синхронно с `HOME`, который форсит launcher (`/home/slin`). То есть привести к
единому HOME: маунт `/home/slin/.orchestrator-ssh``/home/slin/.ssh:ro`.
- Маунт `/home/slin/.claude` и `.claude.json` — оставить; проверить доступ uid 1000.
### 4.2 Доступ к claude creds
- Обеспечить, что `/home/slin/.claude/.credentials.json` читается uid 1000
(на хосте — операция Owner; в ТЗ зафиксировать команду и проверку).
### 4.3 src/agents/launcher.py
- Проверить, что `HOME=/home/slin` остаётся валиден под uid 1000 (домашний каталог
существует и доступен). Менять ТОЛЬКО при доказанной необходимости.
- Не менять CLAUDE_BIN, если запуск под 1000 подтверждён.
### 4.4 Dockerfile
- Менять при необходимости (например, гарантировать существование `/home/slin` и
права). `git config --system --add safe.directory '*'` уже есть — оставить.
## 5. Изменения API
Нет.
## 6. Изменения схемы БД
Нет.
## 7. Новые QG checks
Нет. Существующий staging-гейт (`check_staging_status`, ORCH-35) — обязательная
страховка перед прод-деплоем self (без изменений).
## 8. Артефакты pipeline, которые должны быть созданы/обновлены
- `06-adr/ADR-NNN-<slug>.md` — выбор варианта + обоснование (мины 13, SSH, HOME).
- `docs/operations/INFRA.md` — обновить блок volumes (SSH target) и, при изменении
режима, упоминание uid рантайма.
- `CHANGELOG.md` — запись `fix:`/`refactor:` по Conventional Commits.
- `12-review.md`, `13-test-report.md`, `15-staging-log.md` — по ходу конвейера.
## 9. Порядок безопасного внедрения (требование)
1. Живая разведка прав creds/sock/ssh ДО кода.
2. Применить и проверить на **staging (8501)** end-to-end.
3. Прод-рестарт контейнера под новым uid — только в окно тишины (нет активных задач).
4. Регресс на хосте: новые tracked-артефакты `1000:1000`, `git pull` под slin без ошибок.
## 10. Зависимости и host-prerequisites (действия на хосте, вне кода)
Эти пункты — предусловия для Варианта 1; их выполняет Owner на хосте mva154 (в гит
не коммитятся, но фиксируются в ADR/INFRA как обязательная процедура). Без них
переход контейнера на uid 1000 ломает конвейер (МИНА 2 — блокер).
| # | Предусловие | Команда / проверка | Зачем |
|---|-------------|--------------------|-------|
| P-1 | Доступ uid 1000 к claude creds | `chown -R 1000:1000 /home/slin/.claude` (вкл. `.credentials.json`); проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json` | МИНА 2: без доступа preflight ORCH-044 завернёт весь конвейер |
| P-2 | SSH-ключи в HOME нового uid и читаемы | ключи в `/home/slin/.orchestrator-ssh` читаемы uid 1000; маунт ведёт в `/home/slin/.ssh` (см. §4.1) | деплой по ssh (DEPLOY_SSH_*) |
| P-3 | Подтверждение uid:gid рантайма | `id slin``uid=1000 gid=1000`; `/repos` и `/app/data` уже `1000:1000` (подтверждено разведкой) | целевые файлы создаются под slin |
| P-4 | Окно тишины для рестарта self | `GET /status` → нет активных задач перед рестартом прод-контейнера | self-hosting: общий инстанс с enduro-trails |
> **Открытый выбор для ADR (не решается анализом):** способ обеспечения P-1 —
> `chown` creds (рекомендация разведки) vs. ослабление read-прав vs. отказ от
> Варианта 1 в пользу Варианта 3 (chown-хук). Анализ фиксирует P-1 как
> обязательное предусловие при любом из вариантов 1/2; для Варианта 3 — неактуально.
## 11. Подтверждённые факты текущего рантайма (anchor для Dev)
Сверено с веткой `feature/ORCH-040-root-git` на 06.06:
- `docker-compose.yml`: оба сервиса имеют `group_add: ["999"]` (МИНА 1 — НЕ удалять);
SSH-маунт обоих = `/home/slin/.orchestrator-ssh:/root/.ssh:ro` (требует правки target);
claude-маунты = `/home/slin/.claude` и `/home/slin/.claude.json:ro`.
- `src/agents/launcher.py`: `HOME="/home/slin"` форсится в env Popen (стр. 326) и в
git_env (стр. 513); `CLAUDE_BIN="/opt/claude-code/bin/claude.exe"` (стр. 187).

View File

@@ -0,0 +1,62 @@
# 03 — Критерии приёмки: ORCH-040
Work Item: **ORCH-040**
Каждый критерий имеет чёткое условие PASS/FAIL. Задача считается принятой, когда
**все** критерии = PASS.
## AC-1 — Артефакты создаются под uid хоста (корневой критерий)
- **PASS**: после прогона тестовой задачи конвейером end-to-end новые tracked-файлы
в `/home/slin/repos/orchestrator/docs/work-items/*` и в worktree
(`/repos/_wt/...`) имеют владельца `slin:slin` (1000:1000).
`ls -ld /home/slin/repos/orchestrator/docs/work-items/*`НЕ `root:root`.
- **FAIL**: появляются новые `root:root` tracked-файлы.
## AC-2 — git под slin работает без ручного chown
- **PASS**: на хосте под `slin` `git -C /home/slin/repos/orchestrator pull`,
`git status`, `git reset` выполняются без `Permission denied` /
`insufficient permission for adding an object`.
- **FAIL**: любая из команд падает на правах.
## AC-3 — claude-агенты стартуют (preflight ok)
- **PASS**: `claude-auth`/preflight проходит; агент конвейера запускается и
завершается `exit_code=0` (не `Not logged in`, не отказ доступа к creds).
- **FAIL**: агент падает на авторизации/чтении `/home/slin/.claude`.
## AC-4 — docker.sock доступен (деплой не сломан)
- **PASS**: из контейнера под новым uid `docker ps` / docker-операции деплоя
(ORCH-36 путь) работают — доступ через gid 999 сохранён (`group_add: ["999"]`).
- **FAIL**: docker-операции отваливаются (`permission denied` на сокете).
## AC-5 — SSH-деплой работает
- **PASS**: ssh-ключи читаются из домашнего каталога нового uid; деплой-хук по ssh
(`DEPLOY_SSH_*`) выполняется.
- **FAIL**: ssh не находит/не читает ключи (маунт указывает на чужой HOME).
## AC-6 — Конвейер не сломан (без регресса)
- **PASS**: тестовая задача проходит стадии без падения запуска конвейера; доступ к
Plane/Gitea из агентов сохранён; `pytest tests/ -q` зелёный.
- **FAIL**: конвейер встаёт / тесты падают.
## AC-7 — Проверено на staging ДО прода
- **PASS**: изменение прогнано на staging (8501), `15-staging-log.md`
`staging_status:` положительный; прод-рестарт выполнен в окно тишины.
- **FAIL**: изменение применено сразу на прод без staging-прогона.
## AC-8 — Документация обновлена (golden source)
- **PASS**: `docs/operations/INFRA.md` (блок volumes / SSH target / uid рантайма)
и `CHANGELOG.md` обновлены; ADR с выбором варианта и обоснованием создан в
`06-adr/`. Reviewer подтверждает.
- **FAIL**: код изменён, документация/ADR не обновлены.
## AC-9 — Прод-контейнер не уронен вне окна тишины
- **PASS**: рестарт self выполнен без активных задач; конвейер enduro-trails не
пострадал.
- **FAIL**: рестарт во время активных задач / падение прод-инстанса.
## AC-10 — Host-prerequisites зафиксированы и выполнены
- **PASS**: предусловия P-1…P-4 (TRZ §10 / BRD §8) описаны в ADR/INFRA как
обязательная процедура Owner; P-1 (доступ uid 1000 к claude creds) фактически
обеспечен — подтверждается прохождением AC-3.
- **FAIL**: фикс применён без обеспечения доступа к creds (P-1) → preflight/конвейер
падает; либо предусловия нигде не задокументированы.

View File

@@ -0,0 +1,81 @@
work_item: ORCH-040
description: >
Инфра-фикс: контейнер/агенты не плодят root-файлы в хостовом репо.
Часть проверок автоматизируема через pytest (валидация compose-конфига),
часть — обязательные ops/integration проверки на staging и хосте (manual),
т.к. касаются прав файловой системы хоста и рантайма docker.
tests:
# --- Автоматизируемые (pytest, парсинг docker-compose.yml) ---
- id: TC-01
type: unit
description: >
docker-compose.yml: оба сервиса (orchestrator, orchestrator-staging)
имеют user: "1000:1000" (при выборе Варианта 1).
module: tests/test_orch040_compose.py
expected: PASS
- id: TC-02
type: unit
description: >
docker-compose.yml: оба сервиса сохраняют group_add со значением "999"
(доступ к docker.sock не потерян — МИНА 1).
module: tests/test_orch040_compose.py
expected: PASS
- id: TC-03
type: unit
description: >
docker-compose.yml: SSH-маунт согласован с HOME агента — target каталога
.ssh лежит под /home/slin (а не /root/.ssh), для обоих сервисов.
module: tests/test_orch040_compose.py
expected: PASS
- id: TC-04
type: unit
description: >
launcher: HOME, форсимый в окружении агента и git_env, указывает на каталог,
совместимый с SSH/claude-маунтами (/home/slin) — нет рассинхрона HOME vs uid.
module: tests/test_orch040_compose.py
expected: PASS
# --- Регресс существующего поведения ---
- id: TC-05
type: unit
description: >
Весь существующий набор тестов зелёный (нет регресса логики конвейера/launcher).
module: tests/ # pytest tests/ -q
expected: PASS
# --- Integration / ops (staging 8501, затем хост) ---
- id: TC-06
type: integration
description: >
На staging (8501) прогнать тестовую задачу конвейером end-to-end; артефакты
worktree и docs создаются под 1000:1000 (НЕ root:root). Проверка AC-1.
module: scripts/staging_check.py # + ls -ld на хосте
expected: PASS
- id: TC-07
type: integration
description: >
После staging-прогона на хосте под slin: git -C /home/slin/repos/orchestrator
pull/status/reset без Permission denied. Проверка AC-2.
module: manual/host-check
expected: PASS
- id: TC-08
type: integration
description: >
claude preflight/auth проходит под новым uid: агент стартует и завершается
exit_code=0 (creds /home/slin/.claude читаются). Проверка AC-3 (МИНА 2).
module: manual/staging-agent-run
expected: PASS
- id: TC-09
type: integration
description: >
docker.sock доступен из контейнера под uid 1000 (docker ps работает) и
ssh-деплой-хук выполняется. Проверка AC-4, AC-5 (МИНА 1 + SSH).
module: manual/staging-deploy-path
expected: PASS

View File

@@ -0,0 +1,109 @@
# ADR-001: Контейнер и агенты бегут под uid:gid хоста (1000:1000), а не root
- **Статус:** Accepted
- **Дата:** 2026-06-06
- **Задача:** ORCH-040
- **Связи:** глобальный [adr-0005](../../../architecture/adr/adr-0005-container-runs-as-host-uid.md), adr-0003 (staging-гейт — страховка перед прод-рестартом self), adr-0001 (`is_self_hosting_repo`).
## Контекст
Контейнер `orchestrator` (prod, 8500) работает под `uid=0 (root)` и монтирует хостовый
`/home/slin/repos``/repos` (rw). Claude-CLI агенты запускаются через
`subprocess.Popen` **внутри контейнера**, т.е. под тем же root. Все артефакты конвейера
(git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) появляются на **хосте**
с владельцем `root:root`.
Следствие: при каждом деплое прода `git pull` / `git reset` под пользователем `slin`
(uid 1000) падает с `insufficient permission for adding an object to repository database`
/ `Permission denied`. Каждый деплой ломается, пока вручную не сделать `chown`.
Разведкой (0506.06) подтверждено:
- `slin = uid 1000 gid 1000`, в группах `sudo`, `docker(999)`; на хосте `/repos` и
`/app/data` уже `1000:1000`.
- launcher **уже** форсит `HOME=/home/slin` в двух местах: env `Popen` (`launcher.py:326`)
и `git_env` (`launcher.py:513`). Креды читаются из `/home/slin/.claude`.
- `docker-compose.yml`: оба сервиса имеют `group_add: ["999"]` (доступ к docker.sock —
через gid 999, **не** через root); SSH-маунт обоих = `/home/slin/.orchestrator-ssh:/root/.ssh:ro`.
- `CLAUDE_BIN=/opt/claude-code/bin/claude.exe` (`launcher.py:187`), `+x` для всех.
- Dockerfile содержит `git config --system --add safe.directory '*'`.
## Рассмотренные варианты
1. **Вариант 1 (выбран): `user: "1000:1000"` в docker-compose для обоих сервисов.**
Контейнер целиком бежит под uid 1000. Все файлы сразу `slin:slin`, git на хосте без
chown. Лечит корень проблемы одной декларативной строкой на сервис, без нового кода.
2. **Вариант 2: drop-privileges только для subprocess агента** (`gosu` / `preexec_fn setuid`).
Контейнер остаётся root, агент бежит под 1000. Точечно, но: новый код в горячем пути
launcher, два класса процессов с разными uid в одном контейнере (uvicorn root vs агент
1000), сложнее отлаживать, выше риск регресса конвейера. Корень (root-владение из самого
uvicorn-процесса при операциях с `/repos`) лечится не полностью.
3. **Вариант 3 (fallback): chown-хук нормализации прав после стадии**
(`chown -R 1000:1000` worktree/docs). Лечит симптом, не причину; требует root внутри
контейнера (т.е. несовместим с В1) и добавляет хрупкий пост-шаг в каждый переход стадии.
## Решение
Принимаем **Вариант 1**. Изменения (применяет Dev на стадии development):
1. **`docker-compose.yml`** — для **обоих** сервисов (`orchestrator`, `orchestrator-staging`):
- добавить `user: "1000:1000"`;
- **сохранить** `group_add: ["999"]` (МИНА 1 — НЕ удалять);
- изменить target SSH-маунта `/root/.ssh``/home/slin/.ssh`, чтобы он совпал с
`HOME=/home/slin`, который форсит launcher. Итог: `/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`;
- claude-маунты (`/home/slin/.claude`, `/home/slin/.claude.json:ro`) — оставить как есть.
2. **`src/agents/launcher.py`** — НЕ менять. `HOME=/home/slin` и
`CLAUDE_BIN=/opt/claude-code/bin/claude.exe` остаются валидными под uid 1000
(`/home/slin` материализуется bind-маунтами; бинарь исполним для всех). Правка
допустима ТОЛЬКО при доказанной поломке запуска под 1000.
3. **`Dockerfile`** — НЕ менять. Отдельный non-root user внутри образа не создаём:
numeric `user: "1000:1000"` работает без записи в `/etc/passwd`; `safe.directory '*'`
уже покрывает git над bind-маунтом. Правка допустима только если запуск под 1000
выявит отсутствующий каталог/право.
### Host-prerequisites (вне кода, выполняет Owner — обязательная процедура)
Без них переход на uid 1000 ломает конвейер. Фиксируются здесь и в INFRA.md как
обязательная процедура; в git не коммитятся.
| # | Предусловие | Команда / проверка | Зачем |
|---|-------------|--------------------|-------|
| P-1 (блокер) | uid 1000 читает claude creds | `chown -R 1000:1000 /home/slin/.claude`; проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json` | МИНА 2: иначе preflight (ORCH-044) завернёт весь конвейер |
| P-2 | ssh-ключи читаемы uid 1000 и в новом HOME | ключи в `/home/slin/.orchestrator-ssh` читаемы 1000; маунт ведёт в `/home/slin/.ssh` | деплой по ssh (`DEPLOY_SSH_*`) |
| P-3 | uid:gid рантайма подтверждён | `id slin``1000:1000`; `/repos`, `/app/data` уже `1000:1000` | целевые файлы под slin |
| P-4 | рестарт self только в окно тишины | `GET /status` без активных задач перед рестартом prod | self-hosting: общий инстанс с enduro-trails |
**Выбор способа P-1:** `chown -R 1000:1000 /home/slin/.claude` (рекомендация разведки).
Обоснование: креды и так принадлежат slin по смыслу; chown проще и надёжнее ослабления
read-битов и не оставляет файл world-readable. Маунт `/home/slin/.claude` оставлен rw —
claude CLI может обновлять токен; под uid 1000 после chown это работает.
## Порядок безопасного внедрения (обязателен)
1. Применить и проверить **на staging (8501)** end-to-end (артефакты → `1000:1000`,
агент `exit_code=0`, docker.sock и ssh-деплой живы) — `15-staging-log.md`,
гейт `check_staging_status`.
2. Прод-рестарт под новым uid — **только в окно тишины** (P-4).
3. Регресс на хосте: новые tracked-артефакты `1000:1000`, `git pull` под slin без ошибок.
## Последствия
**Плюсы:**
- Корень устранён: артефакты создаются под `slin:slin`, ручной `chown` после деплоя не нужен.
- `HOME` теперь консистентен по всем осям (uid = claude = ssh = `/home/slin`); устранён
скрытый рассинхрон SSH-маунта (`/root/.ssh`) с форсимым HOME.
- Минимальная поверхность изменения: декларативный compose, без нового кода в launcher.
**Минусы / ограничения:**
- Появляется жёсткая привязка к `uid 1000` хоста — задокументирована в INFRA.md;
при переносе на другой хост uid пересматривается.
- Требуются host-prerequisites (P-1…P-4) — часть фикса не закрывается кодом; P-1 — блокер.
- Прод-рестарт self = групповой риск (enduro-trails) → строго окно тишины (P-4),
страховка — staging-гейт (adr-0003).
**Вне объёма:** массовый `chown` уже существующих `root:root` файлов в истории (разовая
операция Owner, команда описана в INFRA.md); логика конвейера/QG/схема БД — без изменений.
```

View File

@@ -0,0 +1,47 @@
# 07 — Инфра-требования: ORCH-040
Work Item: **ORCH-040** · Решение: [ADR-001](06-adr/ADR-001-run-agents-as-host-uid.md) (Вариант 1)
> Требования к рантайму/инфре, которые Dev обязан реализовать, а Reviewer — проверить.
> Топология стадий и БД **не меняются**. Меняется только runtime-uid контейнера и target SSH-маунта.
## R-1 — runtime uid контейнера
- Оба сервиса в `docker-compose.yml` запускаются под `user: "1000:1000"`.
- `group_add: ["999"]` **сохраняется** на обоих (docker.sock через gid 999, МИНА 1).
## R-2 — SSH-маунт согласован с HOME
- target SSH-маунта = `/home/slin/.ssh` (не `/root/.ssh`) на обоих сервисах.
- Совпадает с `HOME=/home/slin`, форсимым в `src/agents/launcher.py` (L326, L513).
- Источник (`/home/slin/.orchestrator-ssh`) и режим `:ro` — без изменений.
## R-3 — claude-маунты без изменений
- `/home/slin/.claude` (rw) и `/home/slin/.claude.json:ro` остаются.
- Доступ под uid 1000 обеспечивается host-prerequisite P-1 (chown creds), см. ADR.
## R-4 — образ и launcher без изменений (по умолчанию)
- `Dockerfile` не меняется (numeric uid не требует записи в `/etc/passwd`;
`safe.directory '*'` уже есть). Изменение допустимо только при доказанной поломке под 1000.
- `src/agents/launcher.py` не меняется (`HOME`, `CLAUDE_BIN` валидны под 1000).
## R-5 — host-prerequisites (Owner, вне кода)
P-1…P-4 из ADR §«Host-prerequisites» — обязательная процедура. P-1 (доступ uid 1000 к
claude creds) — блокер: без него preflight (ORCH-044) заворачивает конвейер.
## R-6 — порядок внедрения
1. staging (8501) end-to-end → `15-staging-log.md` / `check_staging_status` зелёный;
2. прод-рестарт self — только в окно тишины (`GET /status` без активных задач, P-4);
3. регресс на хосте: новые tracked-артефакты `1000:1000`, `git pull` под slin без ошибок.
## R-7 — обновление документации (golden source)
Dev в том же PR обновляет:
- `docs/operations/INFRA.md` — блок «Тома (volumes)» (SSH target `/home/slin/.ssh`) и
явное указание runtime-uid (`user: 1000:1000`) контейнеров; команда разового хост-`chown`
legacy `root:root` файлов.
- `CHANGELOG.md` — запись `fix:`/`refactor:`.
- глобальный [adr-0005](../../architecture/adr/adr-0005-container-runs-as-host-uid.md) уже
заведён архитектором; индекс `docs/architecture/adr/README.md` обновлён.
## Что НЕ требуется
- Новых томов, портов, env-переменных — нет.
- Изменения API, схемы БД, реестра QG/стадий — нет.
- Multi-node / облачные сервисы — нет (принципы архитектуры).

View File

@@ -0,0 +1,19 @@
# 10 — Технические риски: ORCH-040
Work Item: **ORCH-040** · Решение: [ADR-001](06-adr/ADR-001-run-agents-as-host-uid.md)
| # | Риск | Вероятн. | Влияние | Митигация |
|---|------|----------|---------|-----------|
| TR-1 | **МИНА 2 — claude creds недоступны uid 1000** → preflight (ORCH-044) валит весь конвейер | Средн. | Крит. (блокер) | P-1: `chown -R 1000:1000 /home/slin/.claude` ДО рестарта; проверка `sudo -u '#1000' test -r .../.credentials.json`; staging-прогон ловит до прода (AC-3) |
| TR-2 | **МИНА 1 — потеря доступа к docker.sock** при смене uid → деплой-операции падают | Низк. | Высок. | `group_add: ["999"]` сохраняется на обоих сервисах (НЕ удалять); проверка `docker ps` из контейнера (AC-4) |
| TR-3 | **SSH-маунт ведёт в чужой HOME** (`/root/.ssh`) → ssh-деплой не находит ключи | Средн. | Высок. | R-2: target → `/home/slin/.ssh`, синхронно с форсимым `HOME`; проверка деплой-хука (AC-5) |
| TR-4 | **Рестарт prod self вне окна тишины** роняет конвейер всех проектов (enduro-trails) | Средн. | Крит. | P-4: рестарт только при `GET /status` без активных задач; страховка — staging-гейт adr-0003 (AC-7, AC-9) |
| TR-5 | **Регресс launcher** при невалидном HOME/uid (`/home/slin` отсутствует, claude.exe не исполним) | Низк. | Высок. | `/home/slin` материализуется bind-маунтами; `claude.exe` `+x` для всех; staging end-to-end + `pytest tests/ -q` (AC-6) |
| TR-6 | **Legacy `root:root` файлы в истории** мешают git под slin даже после фикса | Высок. | Средн. | Вне объёма задачи: разовый хост-`chown` делает Owner; команда описана в INFRA.md |
| TR-7 | **Привязка к uid 1000 конкретного хоста** усложняет перенос на другой хост | Низк. | Низк. | Задокументировано в INFRA.md как явное допущение рантайма; пересмотр при миграции хоста |
| TR-8 | **Запись в bind-маунты под 1000** (`/app/data`, `/repos`) при неверных правах хоста | Низк. | Средн. | P-3: `/repos` и `/app/data` уже `1000:1000` (подтверждено разведкой) |
## Сводный вывод
Основной блокер — TR-1 (creds). Все критичные риски снимаются обязательным staging-прогоном
(adr-0003) ПЕРЕД прод-рестартом и выполнением host-prerequisites P-1…P-4. Изменение
декларативное (compose), без правок горячего кода launcher → низкая поверхность регресса.

View File

@@ -0,0 +1,70 @@
---
type: review
work_item_id: ORCH-040
verdict: APPROVED
version: 1
---
# Review ORCH-040
## Summary
Фикс переводит оба compose-сервиса (`orchestrator`, `orchestrator-staging`) на
`user: "1000:1000"` (Вариант 1 из ADR-001 / adr-0005), чтобы артефакты конвейера
создавались как `slin:slin` и git на хосте работал без ручного `chown`. Реализация
точно соответствует ТЗ и ADR, документация (INFRA.md, CHANGELOG.md, work-item ADR-001,
глобальный adr-0005) обновлена в том же PR, host-prerequisites (P-1…P-4) задокументированы.
Полный прогон `pytest tests/ -q`**501 passed**. Блокеров и must-fix нет.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice to have
- [ ] (опц.) AC-1/2/3/4/5 — это runtime/host-критерии; их фактическое PASS подтверждается
на стадиях `testing` и `deploy-staging` (`15-staging-log.md`, `staging_status:`), а не
ревью кода. Зафиксировано как ожидание к следующим стадиям, не как замечание к PR.
## Проверка по осям
**1. Соответствие ТЗ (02-trz.md §4):**
- §4.1 `docker-compose.yml`: оба сервиса получили `user: "1000:1000"` ✅; `group_add: ["999"]`
сохранён (МИНА 1 — не удалён) ✅; SSH-маунт target `/root/.ssh``/home/slin/.ssh` ✅;
claude-маунты (`/home/slin/.claude`, `.claude.json:ro`) не тронуты ✅.
- §4.3 `src/agents/launcher.py` не менялся; `HOME=/home/slin` остаётся на стр. 326 и 513
(подтверждено grep) — согласован с новым SSH target ✅.
- §4.4 `Dockerfile` не менялся (numeric uid не требует записи в `/etc/passwd`,
`safe.directory '*'` уже есть) — в полном соответствии с решением ADR ✅.
- §5/§6/§7: изменений API/БД/QG нет — подтверждено ✅.
**2. Соответствие ADR (ADR-001 + global adr-0005):**
- Выбран и реализован Вариант 1 ровно как описано в ADR (compose-only, без нового кода
в launcher и Dockerfile) ✅.
- Host-prerequisites P-1…P-4 из ADR перенесены в INFRA.md как обязательная процедура Owner ✅.
- Нарушений глобальных ADR нет; связи с adr-0003 (staging-гейт как страховка) учтены ✅.
**3. Качество кода:**
- Изменения декларативные, с поясняющими комментариями и ссылкой на ADR ✅.
- Тесты `tests/test_orch040_compose.py` содержательные: проверяют `user`, сохранение
`group_add 999`, SSH target под HOME и согласованность HOME launcher'а с маунтами
(TC-01…TC-04, привязаны к AC) — не тривиальные ✅.
- Регресс отсутствует: `pytest tests/ -q` → 501 passed ✅.
## Документация
Обновлена корректно и в том же PR (golden source соблюдён, AC-8 PASS):
- `docs/operations/INFRA.md` — добавлен блок «Рантайм-uid (ORCH-040)», host-prerequisites,
блок volumes/SSH target приведён к `/home/slin/.ssh` ✅;
- `CHANGELOG.md` — запись в разделе Fixed ✅;
- `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` — выбор варианта +
обоснование + P-1…P-4 ✅;
- глобальный `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md` (+ запись в
`adr/README.md`) — сквозное решение зафиксировано ✅.
Изменения `src/` Python-кода нет (правка только в `docker-compose.yml` + тесты), но
документация всё равно обновлена — требование §2 CLAUDE.md выполнено с запасом.

View File

@@ -0,0 +1,94 @@
---
type: test-report
work_item_id: ORCH-040
result: PASS
---
# Test Report — ORCH-040
Тема: agent-файлы конвейера создаются под uid хоста (`1000:1000`, slin),
а не `root:root`. Реализация — Вариант 1 (`user: "1000:1000"` в обоих
compose-сервисах), правка только в `docker-compose.yml` + тесты.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Сервис (prod 8500): `/health` → 200 `{"status":"ok"}`; preflight_ok=true (`2.1.142 (Claude Code)`)
- Дата: 2026-06-06T15:06:25Z
- Ветка: feature/ORCH-040-root-git
## Smoke test API (read-only GET, прод-контейнер не трогался)
| Endpoint | Результат |
|----------|-----------|
| GET /health | 200 — `{"status":"ok","service":"orchestrator"}` |
| GET /status | 200 — активная задача ORCH-040 (stage=testing) |
| GET /queue | 200 — counts ok, max_concurrency=1, breaker=closed, preflight_ok=true |
> curl в окружении тестера отсутствует; smoke выполнен эквивалентным запросом
> через `python -m urllib.request` (только GET, без побочных эффектов).
## Результаты (по 04-test-plan.yaml)
| TC ID | Описание | Тип | Результат |
|-------|----------|-----|-----------|
| TC-01 | compose: оба сервиса `user: "1000:1000"` (Вариант 1) | unit | PASS |
| TC-02 | compose: оба сервиса сохраняют `group_add: ["999"]` (МИНА 1, docker.sock) | unit | PASS |
| TC-03 | compose: SSH-маунт target под `/home/slin/.ssh`, согласован с HOME | unit | PASS |
| TC-04 | launcher: форсимый HOME совместим с claude/SSH-маунтами (`/home/slin`) | unit | PASS |
| TC-05 | полный регресс `pytest tests/` зелёный (нет регресса конвейера/launcher) | unit | PASS (501 passed) |
| TC-06 | staging E2E: артефакты worktree/docs создаются `1000:1000` (AC-1) | integration | DEFERRED → deploy-staging |
| TC-07 | хост под slin: `git pull/status/reset` без Permission denied (AC-2) | integration | DEFERRED → deploy-staging |
| TC-08 | claude preflight/auth под uid 1000, агент exit_code=0 (AC-3, МИНА 2) | integration | DEFERRED → deploy-staging |
| TC-09 | docker.sock + ssh-деплой под uid 1000 (AC-4, AC-5) | integration | DEFERRED → deploy-staging |
**О TC-06…TC-09:** по дизайну test-plan'а это ops/integration-проверки на
staging (8501) и хосте, касающиеся прав ФС хоста и docker-рантайма. Они
относятся к стадии `deploy-staging` (их PASS фиксируется в `15-staging-log.md`,
`staging_status:`) и не воспроизводимы в окружении стадии `testing` без
рестарта контейнера под новым uid. Это совпадает с замечанием ревью
(12-review.md, P3): runtime/host-критерии AC-1…AC-5 подтверждаются на
`deploy-staging`, а не при тестировании кода. Запуск деструктивных операций /
рестарт self в рамках стадии testing запрещён (CLAUDE.md, self-hosting).
## Покрытие критериев приёмки (03-acceptance-criteria.md)
| AC | Статус на стадии testing |
|----|--------------------------|
| AC-1 (артефакты под uid хоста) | runtime — проверяется на deploy-staging |
| AC-2 (git под slin) | runtime — проверяется на deploy-staging |
| AC-3 (claude preflight ok) | preflight_ok=true в `/queue`; полное E2E — deploy-staging |
| AC-4 (docker.sock доступен) | конфиг подтверждён TC-02; runtime — deploy-staging |
| AC-5 (SSH-деплой) | конфиг подтверждён TC-03; runtime — deploy-staging |
| AC-6 (конвейер без регресса, pytest зелёный) | **PASS** — 501 passed |
| AC-7 (проверено на staging до прода) | стадия deploy-staging |
| AC-8 (документация/ADR обновлены) | **PASS** — подтверждено ревью (APPROVED) |
| AC-9 (прод не уронен вне окна тишины) | стадия deploy/окно тишины |
| AC-10 (host-prerequisites зафиксированы) | **PASS** — P-1…P-4 в ADR/INFRA |
## Вывод pytest
```
$ python -m pytest tests/ -v --tb=short
platform linux -- Python 3.12.13, pytest-8.3.3, pluggy-1.6.0
configfile: pytest.ini
plugins: anyio-4.13.0, asyncio-0.23.8
...
======================== 501 passed, 1 warning in 8.54s ========================
$ python -m pytest tests/test_orch040_compose.py -v
tests/test_orch040_compose.py::test_tc01_service_runs_as_host_uid[orchestrator] PASSED
tests/test_orch040_compose.py::test_tc01_service_runs_as_host_uid[orchestrator-staging] PASSED
tests/test_orch040_compose.py::test_tc02_group_add_keeps_docker_gid[orchestrator] PASSED
tests/test_orch040_compose.py::test_tc02_group_add_keeps_docker_gid[orchestrator-staging] PASSED
tests/test_orch040_compose.py::test_tc03_ssh_mount_under_home[orchestrator] PASSED
tests/test_orch040_compose.py::test_tc03_ssh_mount_under_home[orchestrator-staging] PASSED
tests/test_orch040_compose.py::test_tc04_launcher_home_matches_mounts PASSED
========================= 7 passed, 1 warning in 0.31s =========================
```
(1 warning — Pydantic V2 deprecation в `src/config.py`, не относится к ORCH-040.)
## Итог
**PASS** — все автоматизируемые тесты (TC-01…TC-05) зелёные, полный регресс
501 passed, smoke API ok, документация/ADR подтверждены ревью. Runtime/host
критерии (TC-06…TC-09, AC-1…AC-5/7/9) корректно отложены на обязательную
стадию `deploy-staging` (8501) — страховку self-hosting перед прод-деплоем.
Задача переходит на стадию **deploy-staging**.

View File

@@ -0,0 +1,74 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T15:10:00+00:00
target: prod orchestrator (8500) + staging orchestrator-staging (8501)
mode: artifact-validated; prod restart handed off to Owner (self-hosting safeguard)
---
# Deploy Log — ORCH-040
## Verdict
`deploy_status: SUCCESS` — deployable artifact validated and ready. The automated
deploy-stage responsibility is complete. **The actual prod-container restart is an
Owner action** (see Handoff) and was deliberately NOT performed by this agent.
## Why no in-task prod restart
ORCH-040 is a **self-hosting** change: it makes the running prod instance
`orchestrator` (8500) run as `user: "1000:1000"` instead of root. Per CLAUDE.md
rule #1 and INFRA.md §Self-hosting, an ORCH task **must not** restart or drop the
prod container — the single prod instance with a shared DB/queue also serves
enduro-trails, so a restart inside the task is a group risk for all projects.
Real prod deploys go through `scripts/orchestrator-deploy-hook.sh` (DEPLOY_HOOK.md)
executed by the Owner, not by the deployer agent.
## What was validated
- **Staging gate green** — `15-staging-log.md``staging_status: SUCCESS`,
10/10 checks PASS on the live staging instance (8501), already running under
`user: "1000:1000"`. Artifacts created as `slin:slin`, agent `exit_code=0`,
docker.sock + ssh-deploy paths live. This is the canonical pre-prod safeguard
(ADR-0003 staging gate, ADR-001 §Порядок безопасного внедрения step 1).
- **Deployable artifact correct** — `docker-compose.yml` on branch
`feature/ORCH-040-root-git` (commit `f81715b`):
- both services have `user: "1000:1000"`;
- `group_add: ["999"]` **present** for both (МИНА 1 — docker.sock access via gid
999, not root — NOT removed);
- SSH mount retargeted `/root/.ssh``/home/slin/.ssh` to match the launcher's
forced `HOME=/home/slin`;
- claude mounts unchanged.
- `src/agents/launcher.py` and `Dockerfile` unchanged, as the ADR mandates.
## Handoff — Owner prod cut-over (out-of-code, ADR-001 §Host-prerequisites & §Порядок)
Perform in this order, **only in a quiet window** (P-4):
1. **P-1 (BLOCKER)**`chown -R 1000:1000 /home/slin/.claude`; verify
`sudo -u '#1000' test -r /home/slin/.claude/.credentials.json`. Without this,
preflight (ORCH-044) will fail the whole pipeline.
2. **P-2** — ssh keys in `/home/slin/.orchestrator-ssh` readable by uid 1000.
3. **P-3** — confirm `id slin``1000:1000`; `/repos`, `/app/data` already `1000:1000`.
4. **P-4** — confirm `GET http://localhost:8500/status` shows **no active tasks**
before restarting prod (shared instance with enduro-trails).
5. Prod cut-over via the deploy hook (conscious prod override):
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
The hook captures the previous image, runs a 60s health loop, and auto-rolls
back on failure.
6. Post-deploy regression: new tracked artifacts are `slin:slin`; `git pull`
under slin works without manual `chown`.
## Summary
| Item | State |
|------|-------|
| Staging gate (`check_staging_status`) | SUCCESS (10/10) |
| Compose artifact (user/group_add/ssh) | correct, МИНА 1 intact |
| In-task prod restart | NOT performed (self-hosting safeguard, by design) |
| Prod cut-over | handed off to Owner (P-1…P-4 + deploy hook) |
| Deploy stage verdict | SUCCESS |

View File

@@ -0,0 +1,37 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T15:08:10+00:00
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. All checks passed.
- **Work item:** ORCH-040
- **Mode:** stub
- **Execution:** canonical — `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub` (ORCH-048, ADR-001)
- **Result:** 10/10 checks PASS (exit code 0)
## Check results
| Check | Result | Detail |
|-------|--------|--------|
| A1 GET /health → 200 status=ok | PASS | body `{status: ok, service: orchestrator}` |
| A2 GET /queue → 200 with counts/max_concurrency/resilience | PASS | keys present |
| A3 ORCH_STAGING=true (not prod) | PASS | `ORCH_STAGING=true` |
| B4 Plane: sandbox project accessible | PASS | found 5 project(s), sandbox=YES |
| B5 Gitea: orchestrator-sandbox accessible, push=true | PASS | admin/push/pull=true |
| B6 Registry: sandbox present, prod ET/ORCH absent | PASS | sandbox=YES, prod-ET=NO, prod-ORCH=NO |
| C7 Create issue in Plane SANDBOX | PASS | HTTP 201 |
| C8 Trigger pipeline via /webhook/plane | PASS | HTTP 200, status=accepted |
| C9a Branch appears in orchestrator-sandbox | PASS | feature/SANDBOX-016-staging-check-e2e |
| C9b Analyst job enqueued in staging queue | PASS | job queued, agent=analyst |
Cleanup (branch, Plane issue, DB rows) completed successfully via try/finally.
> Note: Docker CLI was unavailable in the deployer environment; the canonical
> in-container exec was performed via the Docker Engine API over the unix socket
> (equivalent to `docker exec`). B6 registry-isolation therefore reflects the
> running staging instance's own `.env.staging` process-env — no host-env
> fallback (avoids the ORCH-048 false-FAIL).

View File

@@ -0,0 +1,7 @@
# Business Request: Telegram live-tracker: режим bump (карточка падает вниз при обновлении)
Work Item ID: ORCH-042
## Description
TBD

View File

@@ -0,0 +1,65 @@
# 01 — BRD: Telegram live-tracker, режим bump + русификация карточки
**Work Item:** ORCH-042
**Тип:** UX-улучшение (notifications)
**Приоритет:** средний
**Запрос:** Слава, 05.06. Связь: `feat/telegram-live-tracker` (Variant B+).
**Self-hosting:** да — правка самого оркестратора, проходит через его же конвейер (общая БД/очередь с enduro-trails). См. `docs/operations/INFRA.md`.
## 1. Контекст и проблема
Live-tracker задачи (`src/notifications.py`) — это ОДНА карточка на задачу в Telegram, которая обновляется на каждом переходе стадии через `editMessageText` (Variant B+). Так сделано СПЕЦИАЛЬНО, чтобы убить старую проблему «~15 отдельных карточек/дублей на задачу».
Побочный эффект текущего решения: карточка редактируется **на месте в истории чата**. При активной переписке в чате карточка «тонет» вверху и её неудобно искать — приходится скроллить вверх к старому сообщению, чтобы увидеть актуальный статус задачи.
Дополнительно накопились косметические претензии к тексту карточки: смесь англоязычных меток стадий с русским текстом, неудачная формулировка «Ревью БРД», и финальный технический хвост `deployed` вместо человекочитаемого «Внедрено».
## 2. Цель
1. Дать Славе альтернативный режим отображения трекера — **bump**: при каждом обновлении карточка «падает вниз» свежим сообщением (всегда последняя в чате), но БЕЗ возврата к проблеме дублей (по-прежнему ОДНА карточка на задачу) и БЕЗ спама звуками/пингами.
2. Привести текст карточки к единому русскому виду и поправить формулировки.
## 3. Заинтересованные лица
- **Слава (Owner)** — единственный получатель Telegram-уведомлений; принимает UX.
- **Агенты конвейера** — косвенно: трекер обновляется из `notify_*`-хелперов на каждой стадии.
## 4. Требования (бизнес-уровень)
### 4.1. Режим работы трекера (флаг)
- **BR-1.** Новый конфиг-флаг `ORCH_TRACKER_MODE` с двумя значениями:
- `edit` — текущее поведение (редактирование на месте). **Это ДЕФОЛТ** (обратная совместимость, никакой регрессии без явного включения).
- `bump` — новый режим «карточка падает вниз».
- **BR-2.** Неизвестное/пустое значение флага трактуется как `edit` (безопасный фолбэк, оркестратор не падает).
### 4.2. Поведение режима bump
- **BR-3.** При обновлении карточки в режиме `bump`: старое сообщение удаляется (`deleteMessage`), отправляется новое (`sendMessage`), указатель `tracker_message_id` перенаправляется на новое сообщение. Итог: в чате всегда ровно ОДНА карточка задачи, и она всегда внизу.
- **BR-4.** Bump тихий: новое сообщение отправляется с `disable_notification=true` — карточка всплывает внизу, но БЕЗ звука/пинга на каждой стадии (как и сейчас в edit-режиме).
- **BR-5.** Первое обновление (карточки ещё нет) в режиме `bump` — просто тихо отправить новое и запомнить id (удалять нечего).
### 4.3. Устойчивость (критично — не сломать защиту от дублей)
- **BR-6.** Fallback: если `deleteMessage` не удался (сообщение старше 48 ч / уже удалено / недоступно) — карточка всё равно отправляется заново, оркестратор НЕ падает.
- **BR-7.** Любой сбой нотификации (сеть/таймаут/5xx/Telegram-ошибка) НЕ роняет оркестратор (контракт «never raises» сохраняется) и НЕ плодит дубли карточек в пределах одного обновления.
- **BR-8.** Режим `edit` после изменений работает строго как раньше — без регрессий (защита от ~15 дублей сохранена).
### 4.4. Текстовые правки карточки (применяются в ОБОИХ режимах)
- **BR-9.** Метку «Ревью БРД» заменить на «Подтверждение BRD».
- **BR-10.** После того как задача переведена в Approved (человеческий gate пройден, время ревью зафиксировано), эмодзи в строке подтверждения BRD заменить на галочку (✅) вместо текущей паузы (⏸️). Пока ждём человека — оставить прежний индикатор ожидания.
- **BR-11.** Русифицировать метки стадий карточки: `Analysis → Анализ`, `Architecture → Архитектура`, `Development → Разработка`, `Review → Код ревью`, `Testing → Тестирование`, `Deploy → Внедрение`.
- **BR-12.** В итоговой (последней) строке готовой задачи заменить технический `deployed` на «Внедрено».
## 5. Вне scope
- Изменение состава событий, которые шлются ОТДЕЛЬНЫМИ пингами (approve-gate / deploy-fail / agent-fail / error) — остаётся как есть.
- Изменение формата метрик (токены/стоимость/длительность), макета строк, логики «попытка N».
- Любые изменения в Plane-комментариях агентов (`usage.build_status_comment`).
- Хранение истории карточек / несколько карточек на задачу.
## 6. Влияние на документацию (golden source)
- `CHANGELOG.md` — запись в `[Unreleased]`.
- `docs/architecture/internals.md` (или соответствующая секция про live-tracker) — описать режимы `edit`/`bump` и `ORCH_TRACKER_MODE`.
- `.env.example` — добавить `ORCH_TRACKER_MODE` с пояснением.
## 7. Критерии успеха (резюме)
Слава может выставить `ORCH_TRACKER_MODE=bump` и видеть актуальную карточку всегда внизу чата, одну на задачу, без звона; при откате на `edit` (дефолт) поведение неотличимо от текущего; текст карточки полностью русифицирован по BR-9..BR-12. Полные условия PASS/FAIL — `03-acceptance-criteria.md`.
</content>
</invoke>

View File

@@ -0,0 +1,118 @@
# 02 — ТЗ: Telegram live-tracker, режим bump + русификация
**Work Item:** ORCH-042 · См. `01-brd.md`, `03-acceptance-criteria.md`.
## 1. Задействованные модули `src/`
| Файл | Что меняется |
|------|--------------|
| `src/config.py` | Новое поле `Settings.tracker_mode` (env `ORCH_TRACKER_MODE`). |
| `src/notifications.py` | Новый helper `delete_telegram(message_id)`; ветвление `update_task_tracker` по режиму; текстовые правки в `_BRD_LABEL`, `_TRACKER_STAGES`, BRD-строке `render_task_tracker`, `_done_link`. |
БД — **без изменений** (используется существующая колонка `tasks.tracker_message_id` и хелперы `get_tracker_message_id` / `set_tracker_message_id` в `src/db.py`). API HTTP-эндпоинты оркестратора — **без изменений**. Новые QG checks — **не требуются**.
## 2. Изменения конфигурации (`src/config.py`)
Добавить в класс `Settings` (рядом с блоком «Telegram notifications»):
```python
# ORCH-042: режим live-трекера задачи.
# edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было).
# bump -> при обновлении старое сообщение удаляется и карточка отправляется
# заново вниз чата (deleteMessage + sendMessage + repoint message_id),
# тихо (disable_notification). Одна карточка на задачу в обоих режимах.
# Неизвестное/пустое значение трактуется как edit (см. notifications).
tracker_mode: str = "edit"
```
- `env_prefix = "ORCH_"` уже задан → переменная окружения `ORCH_TRACKER_MODE`.
- Резолюция режима — в `notifications`: всё, что не равно (case-insensitive, trimmed) `"bump"`, считается `edit`. Не падать на любом значении.
## 3. Изменения нотификаций (`src/notifications.py`)
### 3.1. Новый low-level helper `delete_telegram`
Рядом с `send_telegram` / `edit_telegram`. Контракт «never raises».
```python
def delete_telegram(message_id: int) -> bool:
"""Delete a Telegram message. Never raises.
Returns True if the message is gone after the call (deleted now, OR Telegram
says it's already not there / can't be deleted -> treat as "no longer our
problem", caller proceeds to send a fresh card). Returns False only on a
transient failure (network / timeout / 5xx / unknown error) where the old
message may still be alive.
"""
```
Требования к реализации:
- Эндпоинт `https://api.telegram.org/bot{token}/deleteMessage`, тело `{chat_id, message_id}`, `timeout=5`.
- Нет токена/chat_id → вернуть `False` (как и прочие helpers при отсутствии кредов — ничего не отправлено, ничего не удалено).
- `ok:true``True`.
- `ok:false` с описанием «уже нет / нельзя удалить» (маркеры: `"message to delete not found"`, `"message can't be deleted"`, `"message_id_invalid"`) → `True` (сообщение и так недоступно; не транзиент).
- Прочие `ok:false` (неизвестный 400 / 5xx) и исключения (сеть/таймаут) → `False` + `logger.warning`.
- Вынести маркеры в модульную константу (по аналогии с `_GONE_MARKERS`), например `_DELETE_GONE_MARKERS`.
### 3.2. Ветвление `update_task_tracker` по режиму
Сохранить существующий путь `edit` без изменений поведения. Добавить путь `bump`.
Псевдокод целевой логики:
```python
def update_task_tracker(task_id: int):
try:
from .db import get_tracker_message_id, set_tracker_message_id
text = render_task_tracker(task_id)
mode = (_get_settings().tracker_mode or "edit").strip().lower()
mid = get_tracker_message_id(task_id)
if mode == "bump":
# bump: одна карточка, но всегда внизу.
if mid is not None:
delete_telegram(mid) # best-effort; fallback -> всё равно шлём новое
new_mid = send_telegram(text, disable_notification=True)
if new_mid is not None:
set_tracker_message_id(task_id, new_mid)
# send вернул None (нет кредов / транзиент) -> mid не трогаем,
# дубля в пределах вызова нет; перерисуется на следующем переходе.
return
# mode == "edit" (ДЕФОЛТ): существующая логика без изменений.
... # текущий код edit/EDIT_GONE-fallback as is
except Exception as e:
logger.warning(f"update_task_tracker({task_id}) failed: {e}")
```
Инварианты bump-ветки:
- В пределах ОДНОГО вызова отправляется максимум одно новое сообщение → дублей нет (BR-7).
- `set_tracker_message_id` вызывается ТОЛЬКО при успешном `send` (`new_mid is not None`). При сбое send id остаётся прежним; на следующем переходе старый будет удалён (или уже мёртв) и отправлен новый — без накопления карточек.
- `delete_telegram` — best-effort: его результат НЕ блокирует отправку новой карточки (BR-6: delete-fail → всё равно шлём новое).
- Bump всегда тихий: `disable_notification=True` (BR-4).
### 3.3. Текстовые правки (общие для обоих режимов)
| BR | Где | Было | Стало |
|----|-----|------|-------|
| BR-9 | `_BRD_LABEL` (модульная константа) | `"Ревью БРД"` | `"Подтверждение BRD"` |
| BR-10 | `render_task_tracker`, ветка BRD-строки при `review_seconds is not None` | префикс `⏸️` (`⏸️`) | `✅` (`✅`). Ветка ожидания (`review_seconds is None`, с ⏳) — НЕ менять. |
| BR-11 | `_TRACKER_STAGES` (метки) | `Analysis / Architecture / Development / Review / Testing / Deploy` | `Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение` |
| BR-12 | `_done_link` | `"\U0001f4e6 deployed"` | `"\U0001f4e6 Внедрено"` |
Примечания:
- В `_TRACKER_STAGES` меняется ТОЛЬКО display-label (2-й элемент кортежа). Ключи стадий (`analysis`,…) и имена агентов (`analyst`,…) НЕ трогать — они завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД.
- Выравнивание `{label:<13}` и `{_BRD_LABEL:<13}` оставить как есть (все новые русские метки ≤13 символов; «Подтверждение BRD» длиннее — формат просто не паддит, косметика, поведение не ломает).
- Метки используются и в «✅ …»-строках завершённых стадий, и в «🔄 … идёт»-строке активной стадии — обе автоматически станут русскими (правка в одном месте).
## 4. Совместимость и риски
- Дефолт `edit` гарантирует нулевую регрессию без явного включения bump (BR-8). Подробно — `10-tech-risks.md` (заводит архитектор/девелопер при необходимости).
- Самохостинг: изменения только в коде нотификаций, миграций БД нет, перезапуск self — по стандартной страховке `deploy-staging` (8501) перед prod (см. `CLAUDE.md`).
## 5. Артефакты pipeline, которые ДОЛЖНЫ быть обновлены в этом же PR
- `CHANGELOG.md` → запись в `[Unreleased] / Added` (режим bump) + `Changed` (русификация текста).
- `docs/architecture/internals.md` — секция про live-tracker: режимы `edit`/`bump`, `ORCH_TRACKER_MODE`, контракт `delete_telegram`.
- `.env.example``ORCH_TRACKER_MODE=edit` с комментарием.
- Тесты — см. `04-test-plan.yaml`. **Существующие тесты в `tests/test_telegram_tracker.py`, проверяющие англоязычные метки (`"✅ Analysis"`, `"🔄 Deploy"`, `"Review"`) и метку `"Ревью БРД"`, ОБЯЗАТЕЛЬНО обновить под новые русские строки** — иначе регрессия в CI. Это правка существующих ассертов, не изменение контракта.
## 6. Замечания по реализации (без расширения scope)
- Не вводить новых зависимостей; `httpx` уже используется.
- Не менять сигнатуры `send_telegram` / `edit_telegram` / `update_task_tracker` (внешние вызовы из `launcher`/`stage_engine` не трогаются).
- Не менять состав отдельных пингов (approve-gate / error / deploy-fail / agent-fail).
</content>

View File

@@ -0,0 +1,55 @@
# 03 — Критерии приёмки: ORCH-042
Каждый критерий — однозначное условие PASS/FAIL. Покрытие тестами — `04-test-plan.yaml`.
## Конфигурация
- **AC-1.** `Settings.tracker_mode` существует, дефолт `"edit"`, читается из env `ORCH_TRACKER_MODE`.
- PASS: `Settings().tracker_mode == "edit"` без env; `ORCH_TRACKER_MODE=bump``"bump"`.
- FAIL: поле отсутствует / другой дефолт / не читает env.
- **AC-2.** Неизвестное/пустое значение режима трактуется как `edit` (оркестратор не падает).
- PASS: `ORCH_TRACKER_MODE=garbage` (или пусто) → `update_task_tracker` идёт по edit-ветке, исключений нет.
- FAIL: исключение / выбор bump-ветки на мусоре.
## Режим edit (регрессия — поведение как было)
- **AC-3.** Первый вызов (нет `tracker_message_id`): `sendMessage` тихо (`disable_notification=True`), id сохраняется; `editMessageText` НЕ вызывается.
- **AC-4.** Повторный вызов при живом сообщении: `editMessageText` на сохранённый id; новое сообщение НЕ шлётся.
- **AC-5.** `edit` вернул `EDIT_GONE` → шлётся НОВОЕ сообщение, id обновляется (fallback как раньше).
- **AC-6.** `edit` вернул `EDIT_NOT_MODIFIED` или `EDIT_FAILED` → новое сообщение НЕ шлётся, id не меняется (защита от дублей сохранена).
- Все AC-3..AC-6 проверяются при `tracker_mode="edit"` (дефолт). FAIL — любое расхождение с текущим поведением.
## Режим bump
- **AC-7.** Первый вызов в `bump` (нет id): `deleteMessage` НЕ вызывается; `sendMessage` тихо (`disable_notification=True`); возвращённый id сохраняется.
- PASS: ровно один `send_telegram(..., disable_notification=True)`, `delete_telegram` не вызван, `get_tracker_message_id == new_id`.
- FAIL: вызван delete / громкое сообщение / id не сохранён.
- **AC-8.** Повторный вызов в `bump` при существующем id: вызывается `delete_telegram(старый_id)`, затем `send_telegram(..., disable_notification=True)`, затем `tracker_message_id` перенаправляется на новый id.
- PASS: порядок delete→send соблюдён, id == новый.
- FAIL: нет delete / нет send / id остался старым.
- **AC-9.** Bump тихий: новое сообщение всегда с `disable_notification=True`.
- FAIL: `disable_notification` False/отсутствует.
- **AC-10.** Одна карточка на задачу: за один вызов `update_task_tracker` в bump шлётся НЕ более одного нового сообщения.
- FAIL: более одного `send_telegram` за вызов.
## Устойчивость
- **AC-11.** Fallback при delete-fail: если `delete_telegram` вернул False (старое >48ч / транзиент) — новое сообщение всё равно отправляется, id обновляется, исключений нет.
- PASS: `delete_telegram→False` → ровно один send → id == новый.
- FAIL: send пропущен / исключение всплыло.
- **AC-12.** `delete_telegram` классификация (httpx замокан, never raises):
- `ok:true``True`;
- `ok:false` с `"message to delete not found"` / `"message can't be deleted"` / `"message_id_invalid"``True`;
- неизвестный `ok:false` / 5xx → `False`;
- исключение (таймаут/сеть) → `False`;
- нет токена/chat_id → `False`, HTTP-вызов не выполняется.
- **AC-13.** Транзиентный сбой send в bump (send вернул None): `tracker_message_id` НЕ затирается на None; исключений нет; дублей нет (≤1 попытка send за вызов).
- **AC-14.** `update_task_tracker` никогда не выбрасывает исключение ни в одном режиме (контракт «never raises») при любых сбоях БД/сети/Telegram.
## Текстовые правки (оба режима)
- **AC-15.** Метка «Подтверждение BRD» присутствует в карточке там, где раньше была «Ревью БРД»; строки «Ревью БРД» в выводе нет.
- **AC-16.** После прохождения approve-gate (зафиксированы `brd_review_started_at` и `brd_review_ended_at`) строка подтверждения BRD начинается с ✅ (не ⏸️). Пока ждём человека (`brd_review_ended_at` пуст) — индикатор ожидания/⏳ сохраняется (не ✅).
- **AC-17.** Метки стадий в карточке русские: `Анализ`, `Архитектура`, `Разработка`, `Код ревью`, `Тестирование`, `Внедрение`. Английских меток (`Analysis`/`Architecture`/`Development`/`Review`/`Testing`/`Deploy`) в выводе нет — ни в «✅ …»-строках, ни в «🔄 … идёт».
- **AC-18.** Итоговая строка готовой задачи содержит «📦 Внедрено» (не «deployed»).
## Регрессия и качество
- **AC-19.** Состав отдельных пингов не изменён: `notify_approve_requested` шлёт ровно один НЕтихий пинг и стартует BRD-часы; `notify_error` — один НЕтихий пинг; `notify_stage_change` / `notify_agent_started` / `notify_qg_failure`НЕ шлют отдельных сообщений (только refresh трекера).
- **AC-20.** Вся существующая и новая pytest-сюита зелёная (`pytest tests/ -q`). Существующие ассерты в `tests/test_telegram_tracker.py` обновлены под русские метки и «Подтверждение BRD».
- **AC-21.** Документация обновлена в ТОМ ЖЕ PR: `CHANGELOG.md`, `docs/architecture/internals.md` (режимы + `ORCH_TRACKER_MODE` + `delete_telegram`), `.env.example` (`ORCH_TRACKER_MODE`). Отсутствие — REQUEST_CHANGES на ревью.
</content>

View File

@@ -0,0 +1,160 @@
work_item: ORCH-042
description: >
Режим bump live-трекера (delete+send+repoint, тихо, fallback, never-raises),
сохранение режима edit без регрессий, и текстовые правки карточки
(Подтверждение BRD, ✅ после approve, русские метки стадий, «Внедрено»).
Сеть не трогаем: httpx / низкоуровневые helpers мокаются; изолированная temp-БД.
tests:
# --- config ---
- id: TC-01
type: unit
description: "Settings.tracker_mode по умолчанию 'edit' и читается из ORCH_TRACKER_MODE (AC-1)"
module: tests/test_config.py
expected: PASS
- id: TC-02
type: unit
description: "Неизвестное/пустое значение режима -> update_task_tracker идёт по edit-ветке, без исключений (AC-2)"
module: tests/test_telegram_tracker.py
expected: PASS
# --- edit mode regression ---
- id: TC-03
type: unit
description: "edit: первый вызов -> sendMessage тихо, id сохранён, editMessageText не вызван (AC-3)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-04
type: unit
description: "edit: повторный вызов -> editMessageText на сохранённый id, нового send нет (AC-4)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-05
type: unit
description: "edit: EDIT_GONE -> отправка нового, id обновлён (AC-5)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-06
type: unit
description: "edit: EDIT_NOT_MODIFIED и EDIT_FAILED -> нового сообщения нет, id не меняется (AC-6)"
module: tests/test_telegram_tracker.py
expected: PASS
# --- bump mode ---
- id: TC-07
type: unit
description: "bump: первый вызов (нет id) -> delete не вызван, send тихий, id сохранён (AC-7, AC-9)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-08
type: unit
description: "bump: повторный вызов -> delete(старый) затем send(тихо), id перенаправлен на новый, порядок delete->send (AC-8, AC-9, AC-10)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-09
type: unit
description: "bump fallback: delete_telegram->False -> новое всё равно отправлено, id обновлён, без исключений (AC-11)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-10
type: unit
description: "bump: send вернул None (транзиент) -> id не затёрт на None, ровно одна попытка send, без исключений (AC-13)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-11
type: unit
description: "bump: одна карточка за вызов -> send_telegram вызван <=1 раза (AC-10)"
module: tests/test_tracker_bump.py
expected: PASS
# --- delete_telegram classification ---
- id: TC-12
type: unit
description: "delete_telegram: ok:true -> True (httpx замокан)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-13
type: unit
description: "delete_telegram: ok:false 'message to delete not found' / 'message can't be deleted' / 'message_id_invalid' -> True (AC-12)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-14
type: unit
description: "delete_telegram: неизвестный ok:false / 5xx -> False (AC-12)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-15
type: unit
description: "delete_telegram: исключение (таймаут/сеть) -> False, never raises (AC-12, AC-14)"
module: tests/test_tracker_bump.py
expected: PASS
- id: TC-16
type: unit
description: "delete_telegram: нет токена/chat_id -> False, HTTP не вызывается (AC-12)"
module: tests/test_tracker_bump.py
expected: PASS
# --- never raises ---
- id: TC-17
type: unit
description: "update_task_tracker никогда не бросает (DB/сеть сбой) в обоих режимах (AC-14)"
module: tests/test_tracker_bump.py
expected: PASS
# --- text changes ---
- id: TC-18
type: unit
description: "render: метка 'Подтверждение BRD' присутствует, 'Ревью БРД' отсутствует (AC-15)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-19
type: unit
description: "render: approve-gate пройден (brd_review_ended_at задан) -> строка BRD с ✅, не ⏸️ (AC-16)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-20
type: unit
description: "render: ожидание человека (brd_review_ended_at пуст) -> индикатор ожидания/⏳, не ✅ (AC-16)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-21
type: unit
description: "render: русские метки стадий (Анализ/Архитектура/Разработка/Код ревью/Тестирование/Внедрение), английских нет — в ✅- и 🔄-строках (AC-17)"
module: tests/test_telegram_tracker.py
expected: PASS
- id: TC-22
type: unit
description: "render done: итоговая строка содержит '📦 Внедрено', не 'deployed' (AC-18)"
module: tests/test_telegram_tracker.py
expected: PASS
# --- separate alerts regression ---
- id: TC-23
type: unit
description: "Состав отдельных пингов не изменён: approve-gate/error шлют 1 нетихий пинг; stage_change/agent_started/qg_failure не шлют (AC-19)"
module: tests/test_telegram_tracker.py
expected: PASS
# --- full suite ---
- id: TC-24
type: integration
description: "Вся pytest-сюита зелёная; обновлённые ассерты под русские метки проходят (AC-20)"
module: tests/
expected: PASS
</content>

View File

@@ -0,0 +1,85 @@
# ADR-001: Режим bump live-трекера через delete+send+repoint, edit как дефолт
**Work Item:** ORCH-042 · См. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `10-tech-risks.md`.
## Статус
Accepted
## Контекст
Live-tracker (`src/notifications.py`, ветка `feat/telegram-live-tracker`, Variant B+) держит **ОДНУ** карточку на задачу и редактирует её на месте (`editMessageText`) на каждом переходе стадии. Это сознательно убило прежнюю боль — «~15 отдельных карточек/дублей на задачу». Защита от дублей — главный инвариант компонента и не должна регрессировать.
Побочный эффект edit-режима: при активной переписке в чате карточка «тонет» вверху истории — актуальный статус задачи приходится искать скроллом. Слава просит альтернативу: карточка должна всегда быть последней в чате, но без возврата дублей и без звона на каждой стадии.
Дополнительно — косметика текста карточки (смесь EN-меток стадий с RU-текстом, «Ревью БРД», технический хвост `deployed`). Текстовые правки тривиальны и сами по себе архитектурного решения не требуют; ключевое решение — как реализовать новый режим, не сломав инвариант «одна карточка».
Ограничения окружения (см. `CLAUDE.md`, `docs/operations/INFRA.md`):
- Контракт компонента: `update_task_tracker` и low-level helpers **никогда не бросают** (сбой нотификации не должен валить конвейер).
- Self-hosting: правка инструмента, который сейчас в проде и обслуживает другие проекты из общей БД/очереди. Прод-рестарт self — только через `deploy-staging` (8501).
- Telegram Bot API: `deleteMessage` не работает для сообщений старше 48 ч и для уже удалённых/недоступных — это нормальный ожидаемый исход, а не ошибка.
## Решение
### Р-1. Поведение задаётся конфиг-флагом, дефолт `edit` (нулевая регрессия)
Новое поле `Settings.tracker_mode` (env `ORCH_TRACKER_MODE`), значения `edit` | `bump`, **дефолт `edit`**. Резолюция режима — в `notifications`, case-insensitive + trim; всё, что не равно `"bump"` (включая пустое/мусор/None), трактуется как `edit`. Без явного включения bump поведение неотличимо от текущего → нулевая регрессия и безопасный фолбэк (оркестратор не падает на любом значении флага).
### Р-2. Режим bump = delete + send + repoint, инвариант «одна карточка» сохраняется иначе
edit-режим держит одну карточку, *редактируя* её. bump держит одну карточку, *пересоздавая* её внизу:
1. если сохранён `tracker_message_id` — best-effort `delete_telegram(старый_id)`;
2. `send_telegram(text, disable_notification=True)` — новая карточка внизу, тихо;
3. при успехе (`new_mid is not None`) — `set_tracker_message_id` перенаправляется на новый id.
Итог: в чате всегда ровно одна карточка задачи, и она всегда последняя. За **один** вызов `update_task_tracker` отправляется **не более одного** нового сообщения → дублей в пределах вызова нет.
### Р-3. delete — best-effort, никогда не блокирует отправку новой карточки
Новый low-level helper `delete_telegram(message_id) -> bool` с контрактом «never raises». Семантика возврата — «исчезло ли старое сообщение»:
- `ok:true``True`;
- `ok:false` с маркерами «уже нет / нельзя удалить» (`message to delete not found`, `message can't be deleted`, `message_id_invalid`, вынести в константу `_DELETE_GONE_MARKERS`) → `True` (не транзиент, сообщение и так недоступно);
- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`;
- нет токена/chat_id → `False`, HTTP не выполняется.
**Результат `delete_telegram` НЕ влияет на решение отправлять новую карточку** — её шлём всегда (BR-6: delete-fail у сообщения >48 ч → всё равно новое). `False` означает лишь «старое, возможно, ещё живо»; на следующем переходе оно будет удалено повторно (или уже мёртво). Накопления карточек это не даёт, т.к. указатель всегда хранит ровно один id.
### Р-4. repoint только при успешном send (анти-затирание указателя)
`set_tracker_message_id` вызывается **только** при `new_mid is not None`. Если send вернул None (нет кредов / транзиент 5xx/таймаут) — id **не трогаем** (не затираем на None): карточка перерисуется на следующем переходе, дубля нет (≤1 попытка send за вызов). Это симметрично существующему edit-fallback, который тоже не плодит сообщения при транзиенте.
### Р-5. bump всегда тихий
Новая карточка отправляется с `disable_notification=True` — всплывает внизу, но без звука/пинга, как и edit сейчас. Состав отдельных НЕтихих пингов (approve-gate / error / deploy-fail / agent-fail) не меняется (вне scope).
### Р-6. Текстовые правки — в одной точке, общие для обоих режимов
Правки (`_BRD_LABEL` → «Подтверждение BRD»; ✅ вместо ⏸️ после approve-gate; русские display-labels в `_TRACKER_STAGES`; `_done_link` → «Внедрено») затрагивают только **отображаемые** строки. Ключи стадий (`analysis`, …) и имена агентов (`analyst`, …) НЕ меняются — они завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД. Правка `_TRACKER_STAGES` в одном месте автоматически русифицирует и «✅ …», и «🔄 … идёт».
### Что НЕ меняется (границы решения)
- БД: миграций нет, используется существующая колонка `tasks.tracker_message_id` и хелперы `get_tracker_message_id` / `set_tracker_message_id`. → `08-data-requirements.md` не требуется.
- Инфраструктура / топология / порты / контейнеры — без изменений. → `07-infra-requirements.md` не требуется.
- State machine (`src/stages.py`), реестр QG (`src/qg/checks.py`), стадии, компоненты — без изменений. → глобальный (cross-cutting) ADR не требуется, решение локально для компонента notifications.
- Сигнатуры `send_telegram` / `edit_telegram` / `update_task_tracker` — без изменений (внешние вызовы из `launcher`/`stage_engine` не трогаются).
- Новых зависимостей нет (`httpx` уже используется).
## Альтернативы
- **A1. Только bump, без флага.** Отклонено: ломает обратную совместимость и единственного пользователя (Слава может предпочесть edit); рост риска регрессии защиты от дублей. Флаг с дефолтом `edit` даёт мгновенный откат.
- **A2. Pin-сообщение (закрепить карточку).** Отклонено: pin не решает «карточка внизу при переписке», шлёт системное уведомление о закреплении (звон), и усложняет API-контракт. Вне духа «тихого» трекера.
- **A3. send-then-delete (сначала новое, потом удалить старое).** Отклонено как дефолтный порядок: в окне между send и delete в чате видны ДВЕ карточки; при падении на delete остаётся осиротевшая старая → визуальный дубль. delete-then-send гарантирует ≤1 карточку в любой момент при нормальном пути и ≤1 *новую* отправку за вызов в любом случае.
- **A4. Хранить историю/несколько карточек.** Вне scope и противоречит исходному инварианту «одна карточка».
## Последствия
**Плюсы**
- Слава получает актуальную карточку всегда внизу чата, одну на задачу, без звона.
- Нулевая регрессия по умолчанию (edit), мгновенный откат флагом.
- Контракт «never raises» и инвариант «одна карточка» сохранены в обоих режимах.
- Изменения локальны (`config.py` + `notifications.py`), без миграций и без рестарта-критичных зависимостей.
**Минусы / ограничения**
- bump расходует Telegram API на 2 запроса вместо 1 (delete + send) на переход — для одного получателя несущественно (rate-limit Telegram не угрожает).
- При транзиентном delete-fail возможна кратко осиротевшая старая карточка до следующего перехода (она будет вычищена попыткой delete на следующем апдейте) — приемлемо, дублей всё равно не плодит.
- bump теряет визуальную «эволюцию на месте» edit-режима (история чата получает по карточке-замене) — но в чате всегда одна актуальная, что и требуется.
**Риски** — см. `10-tech-risks.md`.
## Связи
- BRD/ТЗ/AC: `docs/work-items/ORCH-042/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`; тест-план `04-test-plan.yaml`.
- Компонент: live-tracker (`src/notifications.py`), `feat/telegram-live-tracker` (Variant B+).
- Контекст self-hosting / staging-страховка: `CLAUDE.md`, `docs/operations/INFRA.md`, `docs/architecture/adr/adr-0003-staging-gate.md`.
- Обновляемая дока (в том же PR, стадия development): `CHANGELOG.md`, `docs/architecture/internals.md` (секция live-tracker: режимы + `ORCH_TRACKER_MODE` + `delete_telegram`), `.env.example`.

View File

@@ -0,0 +1,21 @@
# 10 — Технические риски: ORCH-042
См. `02-trz.md`, `06-adr/ADR-001-tracker-bump-mode.md`, `03-acceptance-criteria.md`.
Шкала: Вероятность × Влияние ∈ {низк., сред., выс.}.
| # | Риск | Вер. | Влияние | Митигация | Контроль (AC/TC) |
|---|------|------|---------|-----------|-------------------|
| R-1 | **Регрессия защиты от дублей** — рефактор `update_task_tracker` ломает edit-ветку, возвращается боль «~15 карточек». | низк. | выс. | edit — дефолт и неизменяемая ветка; bump добавляется отдельной веткой `if mode == "bump"`, edit-код не трогается. Полное покрытие edit-регрессии тестами. | AC-3..AC-6, AC-8; TC-03..TC-06, TC-24 |
| R-2 | **Двойная отправка / накопление карточек в bump** — delete и send рассинхронизированы, в чате >1 карточки. | низк. | сред. | Инвариант: ≤1 `send_telegram` за вызов; `set_tracker_message_id` только при успешном send; delete best-effort и не блокирует. | AC-8, AC-10, AC-11; TC-08, TC-09, TC-11 |
| R-3 | **Затирание `tracker_message_id` на None** при транзиентном send-fail → потеря указателя, следующий апдейт не найдёт старое. | низк. | сред. | repoint только при `new_mid is not None`; при None id сохраняется как есть. | AC-13; TC-10 |
| R-4 | **Нарушение контракта «never raises»** — исключение из `delete_telegram`/новой ветки валит конвейер (групповой риск из-за общей очереди). | низк. | выс. | `delete_telegram` обёрнут try/except → bool; внешний try/except в `update_task_tracker` сохранён; сеть/httpx мокаются в тестах. | AC-12, AC-14; TC-12..TC-17 |
| R-5 | **Ложная классифик. delete-ответа** — неизвестный `ok:false` принят за «исчезло» (или наоборот), вечные ретраи/тишина. | низк. | низк. | Явные `_DELETE_GONE_MARKERS` → True; всё прочее (включая 5xx) → False; повтор delete на следующем апдейте безопасен (идемпотентно). | AC-12; TC-13, TC-14 |
| R-6 | **Падение CI на старых ассертах** — тесты `tests/test_telegram_tracker.py` проверяют EN-метки/«Ревью БРД». | сред. | сред. | ТЗ §5 явно требует обновить существующие ассерты под русские метки и «Подтверждение BRD» в том же PR. | AC-20; TC-18, TC-21, TC-24 |
| R-7 | **Сломанная human-gate индикация** — ✅ показан до прохождения approve-gate (ввод в заблуждение). | низк. | низк. | ✅ только при заданном `brd_review_ended_at`; ветка ожидания (`review_seconds is None`, ⏳) не меняется. | AC-16; TC-19, TC-20 |
| R-8 | **Скрытая зависимость от display-label** — русификация `_TRACKER_STAGES` ломает логику, завязанную на текст метки. | низк. | сред. | Меняется только 2-й элемент кортежа (label); ключи стадий и имена агентов (`_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. | AC-17; TC-21 |
| R-9 | **Self-hosting: прод-сбой при выкатке self** — общая БД/очередь, рестарт орка останавливает все проекты. | низк. | выс. | Изменения только в коде нотификаций, миграций БД нет; обязательная страховка `deploy-staging` (8501) перед prod (CLAUDE.md, INFRA.md, adr-0003). Дефолт edit → даже при выкатке поведение не меняется без явного флага. | стадия deploy-staging; `check_staging_status` |
| R-10 | **Документация не обновлена** в том же PR (internals.md / .env.example / CHANGELOG) → REQUEST_CHANGES. | сред. | низк. | ТЗ §5 и AC-21 фиксируют список; reviewer проверяет наличие. | AC-21 |
## Сводный вывод
Все риски — **низкие по вероятности** при соблюдении инвариантов из ADR-001 (edit-дефолт, ≤1 send/вызов, repoint-only-on-success, never-raises, правка только display-label). Остаточный групповой self-hosting-риск (R-9) полностью покрывается обязательным `deploy-staging`-гейтом и тем, что дефолтное поведение не меняется. Блокеров для перехода на стадию development нет.

View File

@@ -0,0 +1,56 @@
---
type: review
work_item_id: ORCH-042
verdict: APPROVED
version: 1
---
# Review ORCH-042
## Summary
Telegram live-tracker: добавлен режим `bump` (`ORCH_TRACKER_MODE` / `Settings.tracker_mode`, дефолт `edit`) + русификация и косметика карточки. Реализация точно соответствует `02-trz.md` и `06-adr/ADR-001-tracker-bump-mode.md`. Все 21 критерий приёмки покрыты; `pytest tests/ -q`**494 passed**. Документация обновлена в том же PR. Замечаний уровня P0/P1/P2 нет.
## Проверка по осям
### 1. Соответствие ТЗ
- `Settings.tracker_mode = "edit"` + env `ORCH_TRACKER_MODE` — есть (config.py).
- `delete_telegram(message_id) -> bool` — контракт «never raises», `_DELETE_GONE_MARKERS` вынесены в константу, классификация ok/gone/transient/no-creds реализована дословно по ТЗ §3.1.
- Ветвление `update_task_tracker`: bump = delete(best-effort) → send(silent) → repoint только при `new_mid is not None`; edit-ветка сохранена без изменений (§3.2). Инварианты bump (≤1 send/вызов, анти-затирание указателя, delete не блокирует send, всегда тихо) соблюдены.
- Текстовые правки BR-9..BR-12 (`_BRD_LABEL`→«Подтверждение BRD», ✅ вместо ⏸️ после approve-gate, русские display-labels `_TRACKER_STAGES`, `_done_link`→«Внедрено») — на месте; ключи стадий и имена агентов не тронуты.
- БД, API, сигнатуры helpers, зависимости — без изменений (как и требовалось).
### 2. Соответствие ADR (ADR-001)
Реализация соответствует решениям Р-1..Р-6: флаг с дефолтом edit (нулевая регрессия), delete+send+repoint, best-effort delete, repoint только при успешном send, всегда тихий bump, текст в одной точке. Выбран порядок delete-then-send (A3 отклонён обоснованно). Глобальные ADR не нарушены; решение локально для компонента notifications, что зафиксировано в ADR.
### 3. Качество кода
- Defensive-контракты «never raises» соблюдены и в helper, и в `update_task_tracker`.
- Docstrings содержательные; логирование (`debug`/`warning`) корректно разнесено по случаям.
- Security/утечек нет; новых зависимостей нет.
### 4. Качество тестов
- `tests/test_config.py` (AC-1), `tests/test_tracker_bump.py` (AC-7..AC-14: ордеринг delete→send, delete-fail, send=None, ≤1 send, классификация delete_telegram, never-raises), `tests/test_telegram_tracker.py` (AC-2 garbage→edit, AC-15..AC-18 русификация, регрессия edit).
- Существующие англоязычные ассерты обновлены под русские метки и «Подтверждение BRD» (AC-20).
- Тесты содержательные, не тривиальные. `pytest tests/ -q` → 494 passed.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice to have
- [ ] В `_TRACKER_STAGES` строчные комментарии-дубли (`# Анализ` и т.п.) после уже русских меток избыточны — косметика, на поведение не влияет.
## Документация
Обновлена в том же PR, полностью соответствует AC-21:
- `CHANGELOG.md` — записи в `[Unreleased] / Added` (bump-режим + `delete_telegram`) и `Changed` (русификация). ✅
- `docs/architecture/internals.md` — новая секция «Live Telegram tracker»: режимы edit/bump (таблица), `ORCH_TRACKER_MODE`, контракт `delete_telegram`, текстовые правки. ✅
- `.env.example``ORCH_TRACKER_MODE=edit` с комментарием. ✅
- ADR заведён: `06-adr/ADR-001-tracker-bump-mode.md`. ✅
Изменения `src/` (config.py, notifications.py) полностью отражены в документации — правило «документация = golden source» выполнено.

View File

@@ -0,0 +1,78 @@
---
type: test-report
work_item_id: ORCH-042
result: PASS
---
# Test Report — ORCH-042
Telegram live-tracker: режим `bump` (delete+send+repoint, тихо, fallback, never-raises),
сохранение режима `edit` без регрессий, русификация карточки.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: feature/ORCH-042-telegram-live-tracker-bump
- Дата: 2026-06-06
- Prod orchestrator (8500): `/health``{"status":"ok"}`, активна задача #40 (ORCH-042, stage=testing)
## Smoke test API
| Endpoint | Результат |
|----------|-----------|
| GET /health | PASS — `{"status":"ok","service":"orchestrator"}` |
| GET /status | PASS — активная задача ORCH-042 (stage=testing) |
| GET /queue | PASS — queued:0 running:1 done:99 failed:0, breaker=closed |
(`curl` в окружении недоступен — smoke выполнен через `urllib`.)
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | AC | Результат |
|-------|----------|----|-----------|
| TC-01 | Settings.tracker_mode дефолт 'edit', читается из ORCH_TRACKER_MODE | AC-1 | PASS |
| TC-02 | Мусорное/пустое значение → edit-ветка, без исключений | AC-2 | PASS |
| TC-03 | edit: первый вызов → send тихо, id сохранён, edit не вызван | AC-3 | PASS |
| TC-04 | edit: повтор → editMessageText на сохранённый id, нового send нет | AC-4 | PASS |
| TC-05 | edit: EDIT_GONE → отправка нового, id обновлён | AC-5 | PASS |
| TC-06 | edit: EDIT_NOT_MODIFIED/EDIT_FAILED → нового нет, id не меняется | AC-6 | PASS |
| TC-07 | bump: первый вызов → delete не вызван, send тихий, id сохранён | AC-7,9 | PASS |
| TC-08 | bump: повтор → delete(старый)→send(тихо)→repoint, порядок соблюдён | AC-8,9,10 | PASS |
| TC-09 | bump fallback: delete→False → новое всё равно отправлено | AC-11 | PASS |
| TC-10 | bump: send=None → id не затёрт, ≤1 send | AC-13 | PASS |
| TC-11 | bump: одна карточка за вызов (send ≤1) | AC-10 | PASS |
| TC-12 | delete_telegram: ok:true → True | AC-12 | PASS |
| TC-13 | delete_telegram: gone-маркеры → True | AC-12 | PASS |
| TC-14 | delete_telegram: неизвестный ok:false / 5xx → False | AC-12 | PASS |
| TC-15 | delete_telegram: исключение → False, never raises | AC-12,14 | PASS |
| TC-16 | delete_telegram: нет кредов → False, HTTP не вызван | AC-12 | PASS |
| TC-17 | update_task_tracker never raises (оба режима) | AC-14 | PASS |
| TC-18 | render: «Подтверждение BRD» есть, «Ревью БРД» нет | AC-15 | PASS |
| TC-19 | render: approve-gate пройден → строка BRD с ✅ | AC-16 | PASS |
| TC-20 | render: ожидание человека → ⏳, не ✅ | AC-16 | PASS |
| TC-21 | render: русские метки стадий, английских нет | AC-17 | PASS |
| TC-22 | render done: «📦 Внедрено», не «deployed» | AC-18 | PASS |
| TC-23 | состав отдельных пингов не изменён | AC-19 | PASS |
| TC-24 | вся pytest-сюита зелёная | AC-20 | PASS |
Все 24 тест-кейса плана покрыты и пройдены. Критерии AC-1..AC-20 подтверждены
тестами; AC-21 (документация) подтверждён на ревью (12-review.md, verdict APPROVED).
## Вывод pytest
Целевые модули ORCH-042:
```
tests/test_config.py tests/test_telegram_tracker.py tests/test_tracker_bump.py
52 passed, 1 warning in 1.38s
```
Полный регресс:
```
======================== 494 passed, 1 warning in 8.57s ========================
```
(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:4`, не связан с
ORCH-042, существовал ранее, на результат не влияет.)
## Итог
**PASS** — полный регресс 494/494 зелёный, целевые модули 52/52 PASS, smoke API OK.
Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,82 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T10:20:38Z
work_item: ORCH-042
branch: feature/ORCH-042-telegram-live-tracker-bump
commit: 753eea37fc9b0b7bffd9f896ae8149f5a515fc26
target_service: orchestrator
target_port: 8500
deploy_mode: artifact-only
staging_gate: SUCCESS
prod_container_restarted: false
rebuild_required: true
---
# Deploy Log — ORCH-042
## Verdict
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
Реальный `git pull` + `docker compose ... --build` + рестарт прод-контейнера
`orchestrator` (8500) в рамках этой стадии **НЕ выполняется**. Он делегирован
хуку `scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
Владельцем **после** мерджа ветки `feature/ORCH-042-telegram-live-tracker-bump`
в `main`. Guardrail: агент никогда не перезапускает общий прод-инстанс внутри
ORCH-задачи — это self-hosting групповой риск (CLAUDE.md / INFRA.md
§Self-hosting): рестарт прод-орка остановил бы конвейер ВСЕХ проектов.
## Pre-conditions (все ✓)
| Артефакт | Поле | Значение |
|----------|------|----------|
| `12-review.md` | `verdict` | `APPROVED` |
| `13-test-report.md` | `result` | `PASS` |
| `15-staging-log.md` | `staging_status` | `SUCCESS` (10/10 staging-checks, прогон внутри `orchestrator-staging` :8501) |
| `04-test-plan.yaml` | — | покрывает AC задачи |
| ADR | `06-adr/ADR-001-tracker-bump-mode.md` | заведён |
| `CHANGELOG.md` | — | обновлён |
Стадия `deploy` достижима только потому, что условный staging-гейт
(`check_staging_status`, реальный для self-hosting repo=orchestrator) — зелёный.
## Change scope — почему нужен rebuild+restart (но не сейчас)
ORCH-042 меняет **рантайм-код `src/`**, который копируется в образ (`/app/src`)
и исполняется прод-процессом — значит для вступления в силу на проде нужен
rebuild + restart контейнера:
| Файл | Тип | Как доезжает до прода |
|------|-----|------------------------|
| `src/notifications.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
| `src/config.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
| `.env.example` | дескриптор | реальные значения — в `.env` на хосте (не в гит) |
| `docs/**`, `CHANGELOG.md` | docs | мерж в `main` |
| `tests/**` | тесты, не деплоятся | n/a |
`rebuild_required: true`. Изменения добавляют режим **bump** live-tracker'а
Telegram (карточка перемещается вниз при обновлении) + русификацию текста
уведомлений; они активируются новыми env-флагами (см. `.env.example`).
Чтобы новое поведение вступило в силу на проде, прод-инстанс `orchestrator`
(8500) должен быть **пересобран и перезапущен Владельцем через деплой-хук
после мерджа** — не данным агентом.
## Deploy-хук (выполняет Владелец после мерджа в main)
```bash
# на хосте mva154, прод-таргет (порт 8500, profile отсутствует → default)
TARGET_SERVICE=orchestrator \
TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator \
COMPOSE_PROFILE= \
scripts/orchestrator-deploy-hook.sh --deploy
```
Хук: снимает снапшот текущего образа → `git pull origin main` → перезапуск
сервиса → health-check (10×6s, до 60s по `GET /health`) → при провале
**авто-rollback** на предыдущий образ. Прод-env-флаги bump-режима выставляются
в `.env` на хосте до перезапуска.
> ⚠️ Self-hosting: rebuild прод-орка = групповой риск (общая БД + очередь с
> enduro-trails). Деплой проводить в окно низкой активности конвейера;
> страховка — авто-rollback хука и зелёный staging-гейт (8501).

View File

@@ -0,0 +1,58 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T10:19:10+00:00
base_url: http://localhost:8501
work_item: ORCH-042
mode: stub
checks: 10/10 PASS
---
# Staging Gate Log — ORCH-042
Staging test suite completed against the live staging environment
(`orchestrator-staging`, port 8501). All checks passed.
## Execution
Canonical procedure (ORCH-048, ADR-001): run **inside** the
`orchestrator-staging` container so the B6 registry-isolation check reads the
registry from the running instance's own process-env (`.env.staging`).
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
(Executed via the Docker Engine API over the mounted unix socket, since no
docker CLI is present in the agent environment; equivalent to the canonical
`docker exec`.)
**Exit code: 0 → staging_status: SUCCESS**
## Results — 10/10 PASS
### Block A — SMOKE
- ✓ A1 GET /health → 200 status=ok
- ✓ A2 GET /queue → 200 with counts/max_concurrency/resilience
- ✓ A3 ORCH_STAGING=true (not prod)
### Block B — ACCESS
- ✓ B4 Plane: sandbox project accessible (5 projects, sandbox=YES)
- ✓ B5 Gitea: orchestrator-sandbox accessible, push=true
- ✓ B6 Registry: sandbox present, prod ET/ORCH absent (isolation confirmed)
### Block C — E2E (mode=stub)
- ✓ C7 Create issue in Plane SANDBOX (HTTP 201)
- ✓ C8 Trigger pipeline via /webhook/plane (HTTP 200, HMAC)
- ✓ C9a Branch appears in orchestrator-sandbox
- ✓ C9b Analyst job enqueued in staging queue
### Cleanup
- ✓ Branch deleted, Plane issue deleted, staging DB job/task rows removed.
```
============================================================
RESULT: 10/10 checks PASS
============================================================
```

View File

@@ -0,0 +1,7 @@
# Business Request: Безопасная параллель в одном репо: merge-gate + auto-rebase + re-test
Work Item ID: ORCH-043
## Description
TBD

View File

@@ -0,0 +1,114 @@
# 01 — Business Requirements Document (BRD)
**Work Item:** ORCH-043
**Тема:** Безопасная параллель в одном репо: merge-gate + auto-rebase + re-test
**Проект:** orchestrator (self-hosting)
**Автор:** Analyst
**Дата:** 2026-06-06
---
## 1. Контекст и проблема
Оркестратор ведёт несколько work item **параллельно**, каждый в своём изолированном
git worktree / ветке (`feature/ORCH-NNN-slug`, ORCH-2/S-4). Все ветки одного проекта
исходят из общего `origin/main` и в конце конвейера **вливаются обратно в `main`**.
Текущий конвейер валидирует ветку **относительно того состояния `main`, из которого
она была создана**, а не относительно `main` на момент слияния:
- `check_ci_green` (стадия `development`) — CI зелёный **на ветке** (Gitea commit status ветки).
- `check_tests_passed` (стадия `testing`) — вердикт тестировщика по коду **ветки**.
- На стадии `deploy` ветка вливается в `main` (слияние выполняет deployer-агент,
см. `src/webhooks/gitea.py` — комментарий про «deployer merges the PR at the START of its run»).
**Между «ветка проверена» и «ветка влита» `main` мог уйти вперёд** из-за слияния другой
параллельной задачи. Возникает **семантический (логический) конфликт слияния**: git
сливает ветки без текстового конфликта, но объединённый код `main` сломан — тесты,
которые были зелёными на ветке, на обновлённом `main` падают.
### Почему это критично именно здесь (self-hosting)
Проект ORCH правит инструмент, который СЕЙЧАС работает в проде и обслуживает другие
проекты (enduro-trails) из одного инстанса с общей БД и общей очередью (см. `CLAUDE.md`,
`docs/operations/INFRA.md`). Сломанный `main` оркестратора = встал конвейер ВСЕХ проектов.
Две параллельные ORCH-задачи, каждая «зелёная» по отдельности, при последовательном
слиянии способны положить прод.
### Сценарий-иллюстрация
1. Задачи A и B ответвлены от `main@C0`.
2. A проходит конвейер, вливается → `main@C1`.
3. B тестировалась против `C0`; её CI зелёный относительно `C0`. Git-слияние B в `C1`
проходит без текстового конфликта, но `C1` содержит изменения A, ломающие B.
4. `main` становится красным. Конвейер всех проектов деградирует.
---
## 2. Цель
Гарантировать, что ветка вливается в `main` **только если она проверена против
актуального `origin/main`**. Перед слиянием ветка автоматически догоняет `main`
(auto-rebase) и **повторно тестируется** (re-test); зелёный результат на актуальном
`main` — обязательное условие слияния (merge-gate). Слияния в `main` одного репозитория
**сериализуются**, чтобы окно гонки не воспроизводилось между двумя гейтами.
## 3. Заинтересованные стороны
- **Owner / разработчики** — не хотят красный `main` и ручные разборы конфликтов.
- **Все проекты на инстансе** — зависят от живого прод-оркестратора.
- **Агенты конвейера** — получают детерминированный гейт вместо ручной координации.
## 4. Объём (Scope)
### В объёме
1. **Merge-gate** — детерминированный гейт перед слиянием в `main`: пропускает
слияние только если ветка не отстаёт от `origin/main` И повторная проверка зелёная.
2. **Auto-rebase** — если ветка отстаёт от `origin/main`, автоматически догнать `main`
(rebase/merge ветки на актуальный `origin/main`) в worktree и запушить результат.
3. **Re-test** — после auto-rebase повторно прогнать тест-набор на догнанной ветке;
зелёный результат — условие прохода гейта.
4. **Сериализация слияний** — в пределах одного репозитория одновременно «догон+слияние»
выполняет только одна задача (merge-lock), иначе гонка воспроизводится.
5. **Откаты при неуспехе** — текстовый конфликт rebase ИЛИ красный re-test → возврат
задачи на `development` (по образцу существующих откатов) с понятным комментарием.
6. **Конфигурируемость** — пороги/тайм-ауты re-test и поведение гейта вынесены в `settings`.
### Вне объёма
- Изменение логики стадий `analysis` / `architecture` / `review`.
- Замена самого механизма слияния PR в Gitea (UI/настройки репозитория).
- Реальные прод-деплои (остаются за `scripts/orchestrator-deploy-hook.sh`).
- Кросс-репозиторная сериализация (гейт защищает `main` каждого репо отдельно).
## 5. Бизнес-требования (BR)
| ID | Требование |
|----|------------|
| BR-1 | Перед слиянием ветки в `main` оркестратор обязан проверить, что ветка содержит последний `origin/main` (не отстаёт). |
| BR-2 | Если ветка отстаёт — оркестратор автоматически догоняет её до `origin/main` без участия человека (auto-rebase). |
| BR-3 | После догона тест-набор повторно прогоняется; слияние разрешено только при зелёном результате (re-test). |
| BR-4 | Текстовый конфликт при auto-rebase или красный re-test НЕ приводит к слиянию: задача откатывается на `development` для ручного фикса. |
| BR-5 | В пределах одного репозитория «догон+проверка+слияние» сериализуются: две задачи не могут одновременно пройти merge-gate и влиться. |
| BR-6 | Гейт детерминированный (Python/гит-команды + код тестов), а не доверие LLM-агенту. |
| BR-7 | Гейт обязателен минимум для self-hosting репозитория `orchestrator`; применим к любому репо с параллельными задачами. |
| BR-8 | Все события гейта (догон, re-test, проход/откат) логируются и отражаются комментарием в Plane, без рассинхрона стадий. |
## 6. Критерии успеха
- Воспроизводимый ранее сценарий «две зелёные ветки ломают `main`» более не приводит
к красному `main`: вторая ветка либо догоняется и проходит re-test, либо откатывается.
- Прод-контейнер `orchestrator` не перезапускается и не падает в рамках задачи.
- Реестр гейтов и стадий остаётся консистентным (snapshot-тесты обновлены осознанно).
## 7. Риски и ограничения
- **Гонка между двумя гейтами** — снимается merge-lock (BR-5); без него фикс неполон.
- **Долгий re-test** — нужен тайм-аут и понятный откат, а не вис задачи.
- **Force-push догнанной ветки** — допустим только `--force-with-lease` и только по
own-ветке задачи; никогда по `main`.
- **Self-hosting** — любые изменения не должны ронять/рестартить прод-оркестратор;
обязательная страховка стадией `deploy-staging` (порт 8501) сохраняется.
- Окончательное место встройки в конвейер (новая стадия / гейт существующего перехода /
шаг перед слиянием) — **решение архитектора** (ADR), BRD фиксирует требуемое поведение.
## 8. Связанные артефакты
- `02-trz.md` — техническое задание (модули, гейт, конфиг, точки встройки).
- `03-acceptance-criteria.md` — критерии приёмки PASS/FAIL.
- `04-test-plan.yaml` — план тестов.
- Контекст кода: `src/qg/checks.py`, `src/stage_engine.py`, `src/git_worktree.py`,
`src/agents/launcher.py`, `src/webhooks/gitea.py`, `src/stages.py`, `src/config.py`.

View File

@@ -0,0 +1,161 @@
# 02 — Техническое задание (ТЗ)
**Work Item:** ORCH-043
**Тема:** merge-gate + auto-rebase + re-test (безопасная параллель в одном репо)
**Автор:** Analyst
> ТЗ описывает ТРЕБУЕМОЕ поведение и конкретные точки изменения кода. Окончательный
> выбор места встройки в конвейер (новая стадия vs гейт существующего перехода vs шаг
> перед слиянием) и детали reconciliation — **за архитектором** (ADR в `06-adr/`).
> Если ТЗ окажется нереализуемым — вернуть на стадию `analysis`, не комментировать задним числом.
---
## 1. Задействованные модули `src/`
| Модуль | Роль в изменении |
|--------|------------------|
| `src/merge_gate.py` (**новый**) | Ядро фичи: ancestor-check, auto-rebase, re-test, merge-lock. Чистые функции + git-операции в worktree. |
| `src/qg/checks.py` | Новый QG-check `check_branch_mergeable` (merge-gate) + регистрация в `QG_CHECKS`. Переиспользует паттерн `check_tests_local` (pytest в worktree) и `_repo_path`. |
| `src/stages.py` | Встройка merge-gate в `STAGE_TRANSITIONS` (точное место — за архитектором; см. §6). |
| `src/stage_engine.py` | Ветка отката merge-gate → `development` в `_handle_qg_failure_rollbacks` + диспетчеризация нового check в `_run_qg`. |
| `src/git_worktree.py` | Возможные хелперы: проверка «behind origin/main», rebase, push `--force-with-lease`. Не ломать сигнатуры `ensure_worktree` / `get_worktree_path`. |
| `src/config.py` | Новые `settings`: тайм-аут re-test, вкл/выкл гейта, политика отстающей ветки, тайм-аут lock. |
| `src/agents/launcher.py` | Если merge-gate встраивается как шаг перед слиянием на стадии `deploy` — точка, где deployer запускается, может потребовать координации с lock (за архитектором). |
| `tests/` | Новые тесты (см. `04-test-plan.yaml`) + обновление snapshot-тестов реестра/стадий. |
## 2. Функциональные требования к `src/merge_gate.py`
Предлагаемый публичный контракт (имена финализирует архитектор; поведение обязательно):
### 2.1 `branch_is_behind_main(repo, branch) -> bool`
- `git fetch origin main` в main-clone/worktree (best-effort, never-raise → трактуем
как «не удалось определить» и НЕ пропускаем слияние вслепую).
- Ветка считается отстающей, если `origin/main` **не** является предком HEAD ветки
(`git merge-base --is-ancestor origin/main <branch>` → ненулевой код).
### 2.2 `auto_rebase_onto_main(repo, branch) -> (ok: bool, reason: str)`
- Выполняется в изолированном worktree ветки (`ensure_worktree`), НЕ в общем clone.
- Догнать ветку до `origin/main` (rebase либо merge — выбор архитектора; критично:
результат содержит весь `origin/main` и историю/изменения ветки).
- **Текстовый конфликт** → отменить операцию (`git rebase --abort` / `git merge --abort`),
worktree оставить чистым, вернуть `(False, "rebase conflict: <файлы>")`.
- **Чистый догон** → `git push --force-with-lease origin <branch>` (ТОЛЬКО ветка задачи,
НИКОГДА `main`). Вернуть `(True, ...)`.
- Контракт never-raise: любая git/OS-ошибка → `(False, "<reason>")`, не исключение.
### 2.3 `retest_branch(repo, branch) -> (ok: bool, reason: str)`
- Прогнать тест-набор проекта в worktree догнанной ветки. Канон — как в
`check_tests_local`: `python -m pytest` (точная команда/каталог — за архитектором,
согласованно с CI-конфигом `.gitea/workflows/`).
- Тайм-аут `settings.merge_retest_timeout_s`; превышение → `(False, "re-test timeout")`.
- Возврат: `(True, "re-test green")` при коде 0, иначе `(False, "re-test failed: <tail>")`.
### 2.4 Merge-lock (сериализация, BR-5)
- Реализовать межзадачную сериализацию «догон+re-test+слияние» в пределах одного `repo`.
- Допустимые реализации (выбор архитектора): файловый lock в `repos_dir`, advisory-lock,
либо строка-замок в SQLite. Требования: restart-safe, с тайм-аутом
`settings.merge_lock_timeout_s`, корректное освобождение при ошибке/падении.
- Под локом: повторно сверить «не отстаёт» ПОСЛЕ захвата (double-check), т.к. `main`
мог уйти, пока ждали lock.
## 3. Новый QG-check (`src/qg/checks.py`)
```
check_branch_mergeable(repo, work_item_id, branch) -> tuple[bool, str]
```
Поведение (детерминированно, без участия LLM):
1. Захватить merge-lock для `repo` (с тайм-аутом). Не удалось → `(False, "merge-lock busy")`.
2. Если ветка не отстаёт от `origin/main``(True, "branch up-to-date with main")`.
3. Иначе `auto_rebase_onto_main`:
- конфликт → `(False, "rebase conflict: ...")`;
- успех → `retest_branch`:
- зелёный → `(True, "rebased onto main, re-test green")`;
- красный/тайм-аут → `(False, "re-test failed after rebase: ...")`.
4. Освободить lock в `finally`.
- Зарегистрировать в `QG_CHECKS` под ключом `"check_branch_mergeable"`.
- Контракт never-raise (как у соседних чеков): исключение → `(False, "<reason>")`.
> **Опционально (за архитектором):** флаг `settings.merge_gate_enabled`; при `False`
> чек возвращает `(True, "merge-gate disabled")` (безопасный no-op для постепенного
> раскатывания, по образцу условного staging-гейта ORCH-35).
## 4. Изменения схемы БД
- **Не требуется** для базовой реализации (lock через файл/advisory).
- ЕСЛИ архитектор выберет lock через SQLite — добавить таблицу/строку-замок миграцией,
совместимой с текущей инициализацией `src/db.py` (никаких ломающих изменений `tasks`,
`agent_runs`, `jobs`, `events`). Это решение фиксируется в ADR.
## 5. Изменения API
- Новых HTTP-эндпоинтов **не требуется**.
- Допустимо (не обязательно) расширить `GET /status` или `GET /queue` индикатором
«merge-gate: rebasing/re-testing/locked» для наблюдаемости — на усмотрение архитектора,
без изменения существующих контрактов ответов.
## 6. Точки встройки в конвейер (требование + кандидаты)
**Требование:** merge-gate отрабатывает как можно ближе к фактическому слиянию в `main`
и ДО него. Слияние ветки в `main` НЕ должно происходить в обход гейта.
Кандидаты (окончательно — ADR архитектора):
- **(A)** Гейт на переходе `deploy-staging → deploy` или новый под-гейт перед слиянием:
deployer вливает PR на стадии `deploy`, поэтому проверка «догнать+re-test» логично
встаёт непосредственно перед запуском deployer.
- **(B)** Новая стадия `merge-gate` между `deploy-staging` и `deploy` с агентом=None и
`qg="check_branch_mergeable"`.
- **(C)** Перенести само слияние в `main` из ответственности deployer-агента в
детерминированный шаг оркестратора, защищённый merge-gate (более крупное изменение).
При любом варианте, меняющем `STAGE_TRANSITIONS` или `QG_CHECKS`:
- обновить `docs/architecture/README.md` (таблица стадий + реестр QG, §«Конвейер»);
- обновить snapshot-тесты `tests/test_qg_registry_snapshot.py`
(`_EXPECTED_QGS`, `_EXPECTED_TRANSITIONS`) — осознанно, в этом же PR;
- сохранить порядок ключей `STAGE_TRANSITIONS` (от него зависит `get_previous_stage`).
## 7. Откаты (интеграция со `stage_engine`)
В `_handle_qg_failure_rollbacks` добавить ветку для merge-gate FAIL по образцу
`check_staging_status` / `check_deploy_status`:
- `update_task_stage(task_id, "development")`, `set_issue_blocked(work_item_id)`;
- комментарий в Plane (`plane_add_comment`, author="deployer" или системный) с причиной
(конфликт rebase / красный re-test) — дословный `reason` гейта;
- Telegram-алерт (`send_telegram`);
- учитывать `MAX_DEVELOPER_RETRIES`, не плодить бесконечные заворот-циклы.
- В `_run_qg` добавить диспетчеризацию `check_branch_mergeable` с сигнатурой
`(repo, work_item_id, branch)` (как у артефактных чеков).
## 8. Изменения конфигурации (`src/config.py`, env-префикс `ORCH_`)
| Setting | Назначение | Дефолт (предложение) |
|---------|-----------|----------------------|
| `merge_gate_enabled: bool` | Глобальный вкл/выкл гейта | `True` |
| `merge_retest_timeout_s: int` | Тайм-аут повторного прогона тестов | `600` |
| `merge_lock_timeout_s: int` | Тайм-аут ожидания merge-lock | `300` |
| `merge_gate_repos: str` | (опц.) ограничить гейт списком репо; пусто = все | `""` |
Значения и имена финализирует архитектор; задокументировать в `.env.example` и
`docs/architecture/README.md`.
## 9. Требования к наблюдаемости / документации (golden source)
- Обновить `docs/architecture/README.md`: описание merge-gate, auto-rebase, re-test,
merge-lock; при изменении стадий/реестра — соответствующие таблицы.
- Обновить `CHANGELOG.md`.
- Завести ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` (механизм догона,
выбор rebase vs merge, реализация lock, место встройки).
- Все ветки кода — с лог-сообщениями (`logger.info/warning/error`) по образцу соседних
гейтов, чтобы поведение читалось в `/app/data/runs` и логах сервиса.
## 10. Нефункциональные требования
- **Безопасность self-hosting:** никогда не push в `main`; force только `--force-with-lease`
по ветке задачи; прод-контейнер `orchestrator` не рестартить/не ронять.
- **Изоляция:** все git-операции — в worktree ветки (`ensure_worktree`), не в общем clone,
чтобы не словить S-4-гонку параллельных задач.
- **Идемпотентность/restart-safe:** lock и гейт корректно ведут себя при рестарте сервиса.
- **Never-raise** контракт у всех новых чеков/парсеров (как в текущем `src/qg/checks.py`).
- **Совместимость:** не менять сигнатуры/поведение существующих QG-чеков и вебхуков.
## 11. Артефакты pipeline, которые должны быть созданы/обновлены
- `src/merge_gate.py` (новый), изменения в `src/qg/checks.py`, `src/stages.py`,
`src/stage_engine.py`, `src/config.py`, при необходимости `src/git_worktree.py`.
- Новые тесты в `tests/` + обновлённые snapshot-тесты.
- `docs/architecture/README.md`, `CHANGELOG.md`, `.env.example`,
`docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.

View File

@@ -0,0 +1,105 @@
# 03 — Критерии приёмки (Acceptance Criteria)
**Work Item:** ORCH-043 — merge-gate + auto-rebase + re-test
**Автор:** Analyst
Каждый критерий имеет однозначное условие PASS/FAIL. Все критерии должны быть PASS.
---
## AC-1 — Ветка актуальна: гейт пропускает без догона
- **Дано:** ветка содержит последний `origin/main` (не отстаёт).
- **Когда:** выполняется `check_branch_mergeable(repo, work_item_id, branch)`.
- **PASS:** возвращает `(True, ...)` с причиной «up-to-date», auto-rebase НЕ запускается,
ветка не пушится повторно.
- **FAIL:** возвращает `False`, либо выполняет ненужный rebase/push.
## AC-2 — Ветка отстаёт + чистый догон + зелёный re-test → проход
- **Дано:** ветка отстаёт от `origin/main`; rebase проходит без текстового конфликта;
тест-набор на догнанной ветке зелёный.
- **Когда:** выполняется merge-gate.
- **PASS:** ветка догнана до `origin/main`, запушена `--force-with-lease`, re-test зелёный,
гейт возвращает `(True, ...)`.
- **FAIL:** гейт возвращает `False` при чистом догоне и зелёном re-test, либо `main` тронут,
либо push выполнен НЕ через `--force-with-lease`.
## AC-3 — Текстовый конфликт rebase → откат на development, без слияния
- **Дано:** auto-rebase упирается в текстовый конфликт.
- **Когда:** выполняется merge-gate.
- **PASS:** rebase отменён (worktree чист), гейт возвращает `(False, "rebase conflict...")`,
задача переведена на `development`, в Plane — комментарий с причиной, слияния в `main` нет.
- **FAIL:** ветка осталась в конфликтном состоянии, или задача продвинулась к слиянию,
или `main` изменён.
## AC-4 — Красный re-test после догона → откат на development, без слияния
- **Дано:** rebase чистый, но тесты на догнанной ветке падают.
- **Когда:** выполняется merge-gate.
- **PASS:** гейт возвращает `(False, "re-test failed after rebase...")`, задача на
`development`, комментарий в Plane, слияния нет.
- **FAIL:** гейт вернул `True`, либо слияние произошло при красном re-test.
## AC-5 — Сериализация слияний (merge-lock)
- **Дано:** две задачи одного `repo` одновременно подходят к merge-gate.
- **Когда:** обе пытаются пройти гейт.
- **PASS:** «догон+re-test+слияние» выполняет одновременно только одна задача; вторая
ждёт освобождения lock (в пределах `merge_lock_timeout_s`), после чего повторно
сверяет «не отстаёт» и при необходимости догоняется. Воспроизводимый сценарий
«две зелёные ветки ломают main» НЕ приводит к красному `main`.
- **FAIL:** обе задачи параллельно проходят гейт и вливаются, воспроизводя гонку.
## AC-6 — Re-test тайм-аут управляем
- **Дано:** re-test превышает `settings.merge_retest_timeout_s`.
- **PASS:** прогон прерывается, гейт возвращает `(False, "re-test timeout...")`, задача
не виснет, идёт штатный откат.
- **FAIL:** задача висит дольше тайм-аута или падает с необработанным исключением.
## AC-7 — Никогда не push/merge в main напрямую из гейта
- **PASS:** код merge-gate не выполняет `git push ... main` и не форс-пушит `main`;
force-операции — только `--force-with-lease` по ветке задачи.
- **FAIL:** найден любой push/force-push в `main` из логики гейта.
## AC-8 — Изоляция в worktree
- **PASS:** все git-операции гейта идут в worktree ветки (`get_worktree_path` /
`ensure_worktree`), а не в общем `/repos/<repo>` clone.
- **FAIL:** rebase/тесты выполняются в общем clone, создавая S-4-гонку.
## AC-9 — Контракт never-raise
- **Дано:** недоступен git/сеть, бит worktree, отсутствует ветка и т.п.
- **PASS:** `check_branch_mergeable` и функции `merge_gate.py` возвращают `(False, "<reason>")`
(или безопасный фоллбэк), НИКОГДА не пробрасывают исключение в `advance_stage`.
- **FAIL:** любое необработанное исключение всплывает из гейта.
## AC-10 — Реестр QG и снапшоты консистентны
- **PASS:** `"check_branch_mergeable"` зарегистрирован в `QG_CHECKS` и callable;
`tests/test_qg_registry_snapshot.py` (`_EXPECTED_QGS`, при изменении стадий —
`_EXPECTED_TRANSITIONS`) обновлены и зелёные; порядок ключей `STAGE_TRANSITIONS`
сохранён (не сломан `get_previous_stage`).
- **FAIL:** дрейф реестра/стадий без обновления снапшотов; красные snapshot-тесты.
## AC-11 — Интеграция отката в stage_engine
- **PASS:** в `_handle_qg_failure_rollbacks` есть ветка merge-gate FAIL → `development`
с уведомлениями (Plane + Telegram) и учётом `MAX_DEVELOPER_RETRIES`; `_run_qg`
корректно диспетчеризует новый чек.
- **FAIL:** FAIL гейта не приводит к откату, или нет уведомления, или зацикливание заворотов.
## AC-12 — Условный no-op / выключение (если реализовано)
- **Дано:** `settings.merge_gate_enabled = False` (или репо вне `merge_gate_repos`).
- **PASS:** гейт возвращает `(True, "merge-gate disabled")`, конвейер работает как прежде.
- **FAIL:** гейт блокирует/ломает конвейер при выключенном флаге.
## AC-13 — Документация обновлена (golden source)
- **PASS:** обновлены `docs/architecture/README.md` (merge-gate/auto-rebase/re-test,
при изменении — таблицы стадий/реестра), `CHANGELOG.md`, `.env.example` (новые
`ORCH_*` настройки); создан ADR `06-adr/ADR-001-merge-gate.md`.
- **FAIL:** функционал изменён, документация/ADR/CHANGELOG не обновлены (Reviewer →
REQUEST_CHANGES).
## AC-14 — Безопасность self-hosting
- **PASS:** в рамках задачи прод-контейнер `orchestrator` (8500) не рестартился и не падал;
изменения не трогают `.env*`, `docker-compose.yml`, прод-инфраструктуру; страховка
стадией `deploy-staging` сохранена.
- **FAIL:** любой рестарт/падение прод-оркестратора или правка прод-инфры в рамках задачи.
## AC-15 — Зелёный регресс
- **PASS:** `pytest tests/ -q` зелёный целиком (новые тесты ORCH-043 + существующий набор).
- **FAIL:** любой упавший/сломанный существующий тест.

View File

@@ -0,0 +1,163 @@
work_item: ORCH-043
title: "merge-gate + auto-rebase + re-test — безопасная параллель в одном репо"
framework: pytest
notes: >
Тесты на git-операции используют локальные временные репозитории (init bare "origin"
+ рабочая ветка), мокают сеть/Plane/Telegram (как в tests/test_qg.py:
ORCH_DB_PATH/ORCH_REPOS_DIR в tmp, httpx замокан). Каталог тестов/команда pytest для
re-test должны совпадать с CI-конфигом проекта. Финальные имена функций/модулей сверять
с реализацией архитектора.
tests:
# ---- merge_gate core: ancestor / behind detection ----
- id: TC-01
type: unit
description: "branch_is_behind_main → True, когда origin/main ушёл вперёд относительно ветки"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-02
type: unit
description: "branch_is_behind_main → False, когда ветка уже содержит весь origin/main"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-03
type: unit
description: "branch_is_behind_main never-raise: недоступный git/clone → безопасный возврат, не исключение"
module: tests/test_merge_gate.py
expected: PASS
# ---- auto-rebase ----
- id: TC-04
type: unit
description: "auto_rebase_onto_main: чистый догон → (True), ветка содержит origin/main, push выполнен через --force-with-lease"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-05
type: unit
description: "auto_rebase_onto_main: текстовый конфликт → rebase отменён (worktree чист), (False, 'rebase conflict...'), main не тронут"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-06
type: unit
description: "auto_rebase_onto_main НЕ пушит и не форс-пушит main ни при каком исходе (проверка вызванных git-команд)"
module: tests/test_merge_gate.py
expected: PASS
# ---- re-test ----
- id: TC-07
type: unit
description: "retest_branch: pytest rc=0 → (True, 're-test green')"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-08
type: unit
description: "retest_branch: pytest rc!=0 → (False, 're-test failed...') с хвостом вывода"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-09
type: unit
description: "retest_branch: превышен merge_retest_timeout_s → (False, 're-test timeout...'), без виса"
module: tests/test_merge_gate.py
expected: PASS
# ---- merge-lock / сериализация ----
- id: TC-10
type: unit
description: "merge-lock: второй захват того же repo не проходит, пока lock удержан; освобождается в finally/после ошибки"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-11
type: unit
description: "merge-lock restart-safe: устаревший/осиротевший lock не блокирует навсегда (тайм-аут merge_lock_timeout_s)"
module: tests/test_merge_gate.py
expected: PASS
# ---- QG check_branch_mergeable ----
- id: TC-12
type: unit
description: "check_branch_mergeable: ветка актуальна → (True, 'up-to-date'), rebase не вызывался"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-13
type: unit
description: "check_branch_mergeable: отстаёт + чистый rebase + зелёный re-test → (True)"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-14
type: unit
description: "check_branch_mergeable: конфликт rebase → (False, 'rebase conflict...')"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-15
type: unit
description: "check_branch_mergeable: красный re-test после догона → (False, 're-test failed after rebase...')"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-16
type: unit
description: "check_branch_mergeable never-raise: внутренняя ошибка → (False, reason), не исключение; lock освобождён"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-17
type: unit
description: "merge_gate_enabled=False (или репо вне merge_gate_repos) → (True, 'merge-gate disabled') no-op"
module: tests/test_qg_merge_gate.py
expected: PASS
# ---- реестр QG / стадии ----
- id: TC-18
type: unit
description: "'check_branch_mergeable' присутствует в QG_CHECKS и callable"
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-19
type: unit
description: "snapshot STAGE_TRANSITIONS/_EXPECTED_QGS обновлён осознанно и совпадает; порядок ключей сохранён (get_previous_stage не сломан)"
module: tests/test_qg_registry_snapshot.py
expected: PASS
# ---- интеграция со stage_engine (откаты) ----
- id: TC-20
type: integration
description: "_run_qg диспетчеризует check_branch_mergeable с сигнатурой (repo, work_item_id, branch)"
module: tests/test_stage_engine.py
expected: PASS
- id: TC-21
type: integration
description: "merge-gate FAIL → advance_stage откатывает задачу на 'development', set_issue_blocked, комментарий Plane, Telegram-алерт (моки)"
module: tests/test_stage_engine.py
expected: PASS
- id: TC-22
type: integration
description: "merge-gate FAIL уважает MAX_DEVELOPER_RETRIES — нет бесконечного цикла заворотов"
module: tests/test_stage_engine.py
expected: PASS
- id: TC-23
type: integration
description: "merge-gate PASS → задача продвигается к слиянию/деплою, рассинхрона стадий нет"
module: tests/test_stage_engine.py
expected: PASS
# ---- сквозной сценарий гонки ----
- id: TC-24
type: integration
description: >
Воспроизведение бизнес-сценария: A и B от main@C0; A влита (main@C1);
B проходит merge-gate → догоняется до C1 и re-test зелёный → безопасное слияние;
при красном re-test B откатывается, main остаётся зелёным
module: tests/test_merge_gate_race.py
expected: PASS
# ---- конфигурация ----
- id: TC-25
type: unit
description: "Новые ORCH_* настройки (merge_gate_enabled, merge_retest_timeout_s, merge_lock_timeout_s, merge_gate_repos) читаются с дефолтами и env-override"
module: tests/test_config.py
expected: PASS
# ---- регресс ----
- id: TC-26
type: integration
description: "Полный набор pytest tests/ -q зелёный (существующие гейты/вебхуки/стадии не сломаны)"
module: tests/
expected: PASS

View File

@@ -0,0 +1,235 @@
# ADR-001: Merge-gate + auto-rebase + re-test (безопасная параллель в одном репо)
## Статус
Proposed
> Решение архитектора по ТЗ ORCH-043 (`02-trz.md`). Реализует BR-1..BR-8, удовлетворяет
> AC-1..AC-15. Глобальный сквозной аналог — `docs/architecture/adr/adr-0006-merge-gate.md`.
---
## Контекст
Конвейер валидирует ветку относительно того `main`, из которого она была создана, а не
относительно `main` на момент слияния. Между «ветка проверена» и «ветка влита» `main` мог
уйти вперёд из-за слияния другой параллельной задачи → **семантический конфликт слияния**:
git сливает без текстового конфликта, но объединённый код `main` сломан. Для self-hosting
(`orchestrator`) это = красный `main` инструмента, обслуживающего ВСЕ проекты из одного
инстанса с общей БД/очередью.
Ключевые факты текущей архитектуры, влияющие на решение (проверено по коду):
1. **Где происходит слияние в `main`.** Ветку в `main` вливает **deployer-агент в начале
своего запуска на стадии `deploy`** (см. `src/webhooks/gitea.py:336-353` — комментарий
«deployer merges the PR at the START of its run»). Замена самого механизма слияния PR
в Gitea — **вне объёма** (BRD §4). Значит, merge остаётся PR-merge через deployer.
2. **Как запускается deployer стадии `deploy`.** При прохождении `check_staging_status`
на стадии `deploy-staging` движок (`stage_engine.advance_stage`) переводит задачу
`deploy-staging → deploy` и запускает `get_agent_for_stage("deploy-staging") = deployer`.
Этот deployer и делает merge. Значит **merge-gate обязан отработать на ребре
`deploy-staging → deploy`, ДО запуска этого deployer'а**.
3. **Чем триггерится QG.** `advance_stage` вызывается ТОЛЬКО при (а) завершении
LLM-агента (`launcher._try_advance_stage`) или (б) приходе вебхука. **Стадия без агента
не имеет собственного триггера** (стадия `deploy` оценивается, когда заканчивает
deployer, исполняющийся ВО ВРЕМЯ неё). Поэтому новая «пустая» стадия `merge-gate`
между `deploy-staging` и `deploy` зависла бы без триггера (нужен был бы chaining в
движке либо синтетический job — лишняя и не-restart-safe поверхность).
4. **Concurrency.** `max_concurrency` по умолчанию `1`; QG исполняется в monitor-thread
агента. Блокирующее ожидание lock внутри `advance_stage` при одном worker-слоте даёт
**дедлок** (задача B держит слот, ожидая merge задачи A, которой нужен тот же слот).
Сериализация обязана быть **неблокирующей**.
---
## Решение
### 1. Место встройки — ребро `deploy-staging → deploy` (кандидат A ТЗ §6), без новой стадии
Merge-gate — детерминированный шаг в `advance_stage`, исполняемый **после** прохождения
`check_staging_status` и **до** `update_task_stage(deploy)` / запуска deployer'а, который
мержит. `STAGE_TRANSITIONS` **не меняется** (минимальный blast-radius; `get_previous_stage`
не затрагивается; snapshot `_EXPECTED_TRANSITIONS` без изменений). В реестр `QG_CHECKS`
добавляется один ключ `check_branch_mergeable` (snapshot `_EXPECTED_QGS` обновляется
осознанно, AC-10).
Отвергнутые варианты:
- **(B) Новая стадия `merge-gate`** — концептуально честнее, но «пустая» стадия без агента
не имеет триггера (см. Контекст §3). Потребовала бы chaining в `advance_stage`
(не restart-safe для безагентного перехода) или синтетический job-тип в очереди
(поверхность в `launcher`/`queue_worker`, который сейчас умеет только LLM-агентов).
- **(C) Перенос merge в детерминированный шаг оркестратора** — прямо запрещён объёмом
(BRD §4: «Замена механизма слияния PR в Gitea — вне объёма»).
Триггер гейта — **существующее** событие «staging-deployer завершился» → отдельного
механизма триггера не вводим.
### 2. Догон ветки — `rebase` onto `origin/main` + `push --force-with-lease`
Выбор `rebase` (а не merge-commit) обусловлен критериями приёмки AC-2/AC-7, которые прямо
требуют `push --force-with-lease` догнанной ветки. Алгоритм `auto_rebase_onto_main`:
1. `git fetch origin main` в worktree ветки (`ensure_worktree`, AC-8 — изоляция).
2. `branch_is_behind_main`: ветка отстаёт ⇔ `git merge-base --is-ancestor origin/main <HEAD>`
вернул ненулевой код. Не удалось определить (git/сеть) → трактуем как «не пропускаем
вслепую» (never-raise → `(False, reason)`), НЕ как «up-to-date».
3. Не отстаёт → `(True, "branch up-to-date with main")`, rebase/push **не выполняются** (AC-1).
4. Отстаёт → `git rebase origin/main`:
- **текстовый конфликт** → `git rebase --abort`, worktree чист → `(False, "rebase conflict: <файлы>")` (AC-3);
- **чистый rebase** → `git push --force-with-lease origin <branch>` (**ТОЛЬКО ветка задачи; НИКОГДА `main`**, AC-7) → далее re-test.
5. Контракт **never-raise**: любая git/OS-ошибка → `(False, "<reason>")` (AC-9).
`main` гейтом не пушится и не форс-пушится никогда. Единственная force-операция —
`--force-with-lease` по ветке задачи.
### 3. Re-test — `python -m pytest` в worktree догнанной ветки
`retest_branch(repo, branch)`:
- Команда `python -m pytest <merge_retest_target>` (`merge_retest_target` по умолчанию
`tests/`) из корня worktree ветки — согласовано с CI orchestrator
(`pytest tests/ -q`, CLAUDE.md) и паттерном `check_tests_local`.
- Тайм-аут `settings.merge_retest_timeout_s` (дефолт 600); превышение →
`(False, "re-test timeout (<T>s)")` (AC-6), процесс убивается, задача не виснет.
- `returncode == 0``(True, "re-test green")`; иначе `(False, "re-test failed after rebase: <tail>")` (AC-4).
> Гейт по умолчанию реален для self-hosting репо `orchestrator` (BR-7). Для других репо
> применять только при совпадающей тест-команде/раскладке — через `merge_gate_repos`
> (см. §6). Команда re-test параметризуется `merge_retest_target` для портируемости.
### 4. Сериализация слияний — файловый merge-lease на репозиторий (BR-5, AC-5)
Цель: «догон + re-test + **слияние**» одного репо выполняет одновременно только одна
задача. Слияние делает deployer ПОЗЖЕ и в ОТДЕЛЬНОМ запуске, поэтому простой
context-manager-lock внутри гейта окно гонки не закрывает — нужен **lease, живущий от
гейта до фактического merge**.
**Механизм — файловый lease**, БЕЗ изменения схемы БД (ТЗ §4 предпочитает no-schema-change):
- Файл `<repos_dir>/.merge-lease-<repo>.json`, содержимое `{task_id, work_item_id, branch,
acquired_at, pid}`.
- **Acquire — атомарный, НЕблокирующий** (`open(..., O_CREAT|O_EXCL)`):
- файла нет → захват, запись метаданных;
- файл есть, holder == self → идемпотентно «уже наш» (restart/повтор);
- файл есть, holder != self, возраст `< merge_lock_timeout_s` → **busy**;
- файл есть, возраст `>= merge_lock_timeout_s` → **stale, перезахват** с `logger.warning`
(crash-recovery: процесс-холдер умер, не освободив lease).
- **Release — идемпотентный** (`os.remove`, ignore-missing).
- **Restart-safe**: lease на диске; зависший lease реклеймится по возрасту.
**Поведение `check_branch_mergeable(repo, work_item_id, branch)`** (детерминированно, без LLM):
1. Попытка acquire (неблокирующая). Busy → `(False, "merge-lock busy")` — **сигнальный
reason** (НЕ провал кода, см. §5: defer, а не rollback).
2. **Double-check под lease**: повторно `branch_is_behind_main` (пока ждали/между тиками
`main` мог уйти — например, другая задача только что влилась).
3. Не отстаёт → `(True, "branch up-to-date with main")`.
4. Отстаёт → `auto_rebase_onto_main`:
- конфликт → `(False, "rebase conflict: ...")`;
- успех → `retest_branch`: зелёный → `(True, "rebased onto main, re-test green")`;
красный/тайм-аут → `(False, "re-test failed after rebase: ...")`.
5. **При успехе lease НЕ освобождается** — он удерживается до фактического merge.
**При любом провале (конфликт/красный re-test) lease освобождается** (откат на
development, слияния не будет).
6. Регистрация в `QG_CHECKS["check_branch_mergeable"]`; сигнатура `(repo, work_item_id,
branch)` совпадает с дефолтной артефактной → `_run_qg` диспетчеризует без спец-кейса.
**Жизненный цикл lease (точки release):**
- **PR-merged вебхук** ветки (`gitea.handle_pr`, `action=closed & merged`) → release;
- **`deploy → done`** в `advance_stage` (страховочный release);
- **любой откат на development** из merge-gate / `check_deploy_status` → release;
- **возраст `>= merge_lock_timeout_s`** → авто-реклейм (backstop при краше).
### 5. Откаты и defer (интеграция в `stage_engine`, BR-4/BR-8, AC-11)
`check_branch_mergeable` различает два негативных исхода:
- **`reason == "merge-lock busy"` → DEFER, не rollback.** Код задачи исправен — нельзя
слать на development и нельзя тратить `MAX_DEVELOPER_RETRIES`. Движок **повторно
ставит deployer на `deploy-staging` с задержкой** `settings.merge_defer_delay_s`
(через `available_at`-гейт очереди, ORCH-1; задача остаётся на `deploy-staging`).
Неблокирующий defer освобождает worker-слот → задача-холдер успевает влиться (нет
дедлока при `max_concurrency=1`). Повторов defer — ограниченное число
(`merge_defer_max_attempts`), исчерпание → Telegram-алерт + блокировка.
- **`reason` = конфликт rebase ИЛИ красный re-test → rollback на `development`** по образцу
`check_staging_status`/`check_deploy_status` в `_handle_qg_failure_rollbacks`:
`update_task_stage(development)`, `set_issue_blocked`, дословный `reason` в Plane
(`plane_add_comment`, author="deployer"), `send_telegram`, учёт `MAX_DEVELOPER_RETRIES`,
**release lease**. Дословный `reason` встраивается в `task_desc` developer'а (по образцу
ORCH-046), чтобы агент видел суть.
### 6. Конфигурация (`src/config.py`, env-префикс `ORCH_`)
| Setting | Назначение | Дефолт |
|---------|-----------|--------|
| `merge_gate_enabled: bool` | Глобальный вкл/выкл (no-op `(True, "merge-gate disabled")` при False, AC-12) | `True` |
| `merge_gate_repos: str` | CSV-список репо, где гейт реален; пусто = только self-hosting (`orchestrator`) | `""` |
| `merge_retest_timeout_s: int` | Тайм-аут re-test | `600` |
| `merge_retest_target: str` | pytest-цель для re-test (портируемость) | `tests/` |
| `merge_lock_timeout_s: int` | Макс. возраст lease (ожидание/реклейм) | `300` |
| `merge_defer_delay_s: int` | Задержка перед повтором гейта при busy | `60` |
| `merge_defer_max_attempts: int` | Лимит defer-повторов до эскалации | `5` |
Семантика `merge_gate_repos`: пусто → гейт реален ТОЛЬКО для `orchestrator`
(`is_self_hosting_repo`), для прочих — no-op `(True, "merge-gate N/A for <repo>")`
(по образцу условного staging-гейта ORCH-35). Это безопасный поэтапный раскат.
### 7. API
Новых HTTP-эндпоинтов нет. Допустимо (необязательно) добавить в `GET /status`/`GET /queue`
индикатор состояния merge-lease для наблюдаемости — без изменения существующих контрактов.
---
## Последствия
### Плюсы
- Закрывает воспроизводимый сценарий «две зелёные ветки ломают `main`»: перед слиянием
ветка догоняется до актуального `origin/main` и повторно тестируется; слияния
сериализуются lease'ом.
- Минимальный blast-radius: `STAGE_TRANSITIONS` не тронут, snapshot-переходы не меняются,
+1 ключ в `QG_CHECKS`. Триггер — существующее событие, без chaining/новых job-типов.
- Restart-safe и deadlock-safe: файловый lease с реклеймом по возрасту; неблокирующий
acquire + defer вместо блокирующего ожидания.
- Соответствует self-hosting-инвариантам: никогда не пуш/форс-пуш `main`; force только
`--force-with-lease` по ветке задачи; прод-контейнер не рестартится; страховка
`deploy-staging` сохранена.
- Поэтапный раскат через `merge_gate_enabled` / `merge_gate_repos`.
### Минусы / ограничения
- **Merge-gate как «скрытый» под-гейт** ребра `deploy-staging → deploy` не отражён в
`STAGE_TRANSITIONS` (плата за отказ от новой стадии). Смягчение: явно описан в
`docs/architecture/README.md` и этом ADR.
- **Сериализация зависит от вебхука PR-merged** для своевременного release. Деградация
предусмотрена (реклейм по возрасту `merge_lock_timeout_s`), но при «потерянном»
вебхуке возможна задержка следующей задачи до тайм-аута lease.
- **Defer перезапускает staging-deployer** (повторно прогоняет staging-проверку и
перезаписывает `15-staging-log.md`) — переиспользует существующий механизм очереди
ценой лишнего прогона staging. Допустимо; альтернатива (отдельный «retry-gate» job-тип)
дороже по поверхности.
- **Длинный re-test (до 600s)** исполняется синхронно в monitor-thread staging-deployer'а
и удерживает worker-слот на это время (при `max_concurrency=1` приостанавливает прочие
задачи). Это неотъемлемая стоимость «re-test перед слиянием».
- **`rebase --force-with-lease`** переписывает историю ветки и обновляет head открытого PR;
прежний approve ревьюера может пометиться stale в Gitea. На стадии `deploy` ревью
повторно не проверяется — функционально безопасно.
### Влияние на масштаб изменения
Вводится новый модуль (`src/merge_gate.py`), новый QG, lease-подсистема и изменение
поведения ребра `deploy-staging → deploy` + откаты/вебхук. Это **сквозное изменение
конвейера** → рекомендуется лейбл `arch:major-change` и обязательная страховка стадией
`deploy-staging` (8501) перед прод-деплоем самого ORCH-043. Глобальный ADR —
`docs/architecture/adr/adr-0006-merge-gate.md`.
---
## Точки изменения кода (для developer; имена функций — финальные)
- `src/merge_gate.py` (**новый**): `branch_is_behind_main`, `auto_rebase_onto_main`,
`retest_branch`, lease (`acquire_merge_lease`/`release_merge_lease`/реклейм).
- `src/qg/checks.py`: `check_branch_mergeable(repo, work_item_id, branch)` + регистрация в `QG_CHECKS`.
- `src/stage_engine.py`: вызов merge-gate на ребре `deploy-staging → deploy` (после
`check_staging_status`, до advance); ветка rollback merge-gate в
`_handle_qg_failure_rollbacks`; defer-ветка для `"merge-lock busy"`; release lease в
`deploy → done` и в откатах.
- `src/webhooks/gitea.py`: release lease в `handle_pr` (closed & merged).
- `src/db.py` (опц.): `enqueue_job(..., available_at_delay_s=...)` для defer, либо переиспользовать `available_at`.
- `src/config.py`: настройки §6.
- `tests/`: тесты по `04-test-plan.yaml` + обновить `tests/test_qg_registry_snapshot.py`
(`_EXPECTED_QGS` += `check_branch_mergeable`; `_EXPECTED_TRANSITIONS` — **без изменений**).
- Документация: `docs/architecture/README.md` (обновлена в этом PR), `CHANGELOG.md`,
`.env.example` (новые `ORCH_*`).

View File

@@ -0,0 +1,25 @@
# 07 — Требования к инфраструктуре (ORCH-043)
## Вывод: топология не меняется. Новых контейнеров/портов/сервисов нет.
| Аспект | Требование |
|--------|-----------|
| Контейнеры | Без изменений. Прод `orchestrator` (8500) и `orchestrator-staging` (8501) — как есть. |
| Порты | Без изменений. |
| Сеть/внешние сервисы | Без новых зависимостей. Используются существующие git/Gitea (fetch/push) и pytest. |
| Файловая система | Новый артефакт времени выполнения — lease-файл `<repos_dir>/.merge-lease-<repo>.json` (см. `08-data-requirements.md`). Лежит в уже примонтированном `repos_dir` (`/repos`). Дополнительного volume не требуется. |
| Worktree | Переиспользуется существующая изоляция (`/repos/_wt/<repo>/<branch>`, ORCH-2). Все git-операции merge-gate — в worktree. |
| `.env` / compose / прод-инфра | **НЕ изменяются** (AC-14). Новые `ORCH_*` настройки имеют безопасные дефолты (см. ADR-001 §6) и документируются в `.env.example`. |
## Эксплуатационные требования
- **git push прав** для оркестратора достаточно существующих (он уже пушит ветки/PR-артефакты).
Merge-gate пушит ТОЛЬКО ветку задачи (`--force-with-lease`), `main` — никогда.
- **Раскат поэтапно**: `merge_gate_enabled=False` или пустой `merge_gate_repos` (реален
только для `orchestrator`) позволяют включать гейт постепенно без риска для чужих репо.
- **Self-hosting-страховка сохранена**: изменения ORCH-043 проходят обязательную стадию
`deploy-staging` (8501) до прод-деплоя самого оркестратора; прод-контейнер не рестартится
в рамках задачи.
## Рекомендация по процессу
Изменение сквозное (новый QG + поведение ребра `deploy-staging → deploy`) →
рекомендуется лейбл `arch:major-change`. Прод-деплой ORCH-043 — строго через staging-гейт.

View File

@@ -0,0 +1,27 @@
# 08 — Требования к данным / схеме БД (ORCH-043)
## Вывод: изменение схемы SQLite НЕ требуется.
Merge-lease (сериализация слияний, BR-5) реализуется **файлом**, а не таблицей:
- Путь: `<repos_dir>/.merge-lease-<repo>.json` (`settings.repos_dir`, по умолчанию `/repos`).
- Содержимое: `{ "task_id": int, "work_item_id": str, "branch": str,
"acquired_at": "<ISO>", "pid": int }`.
- Жизненный цикл — см. ADR-001 §4 (acquire неблокирующий / release идемпотентный /
реклейм по возрасту `merge_lock_timeout_s`).
### Почему файл, а не таблица БД
- ТЗ §4 прямо предпочитает реализацию без миграции схемы.
- Файловый lease проще делается **restart-safe** (реклейм по mtime/возрасту + `pid`) и не
трогает инициализацию `src/db.py` (никаких изменений `tasks`/`agent_runs`/`jobs`/`events`).
- Атомарность захвата обеспечивается `open(O_CREAT|O_EXCL)` на одном хосте (mva154,
один инстанс) — достаточно для сериализации в пределах одного процесса-оркестратора.
### Существующие таблицы — без изменений
`tasks`, `agent_runs`, `jobs`, `events` не модифицируются. Defer-механизм переиспользует
существующий столбец `jobs.available_at` (ORCH-1) для отложенного повторного запуска
deployer'а**новых столбцов не нужно**.
> Если в будущем потребуется кросс-процессная/мульти-хостовая сериализация — lease можно
> мигрировать в таблицу (или advisory-lock). Это будет отдельным ADR; в рамках ORCH-043
> файловый lease достаточен (один хост, один инстанс).

View File

@@ -0,0 +1,24 @@
# 10 — Технические риски (ORCH-043)
Merge-gate + auto-rebase + re-test. Риски, их влияние и меры снижения. Привязка к AC.
| # | Риск | Влияние | Снижение | AC |
|---|------|---------|----------|----|
| R-1 | **Дедлок при `max_concurrency=1`**: блокирующее ожидание merge-lock в `advance_stage` держит единственный worker-слот, а задаче-холдеру тот же слот нужен для merge. | Полная остановка конвейера (self-hosting = все проекты). | Acquire **неблокирующий**; busy → **defer** (re-enqueue с задержкой, слот освобождается), НЕ блокирующее ожидание. | AC-5 |
| R-2 | **Потерянный PR-merged вебхук** → lease не освобождается вовремя. | Следующая задача ждёт до тайм-аута. | Реклейм lease по возрасту `merge_lock_timeout_s`; release продублирован в `deploy→done` и в откатах. | AC-5 |
| R-3 | **Краш сервиса под lease** (зависший lease-файл после рестарта). | Блокировка merge репо. | Файловый lease с реклеймом по возрасту + `pid`; идемпотентный re-acquire холдером. Restart-safe. | AC-5, AC-9 |
| R-4 | **Долгий re-test (до 600s)** держит worker-слот и блокирует прочие задачи. | Замедление конвейера. | Жёсткий тайм-аут `merge_retest_timeout_s` + kill; осознанная стоимость re-test-перед-merge. | AC-6 |
| R-5 | **Случайный push/force-push в `main`** из логики гейта. | Прямая порча `main` прод-инструмента. | Код гейта НИКОГДА не пушит `main`; единственная force — `--force-with-lease` по ветке задачи; покрыто тестом-стражем. | AC-7 |
| R-6 | **Необработанное исключение** из гейта всплывает в `advance_stage`. | Падение авто-advance, зависшая задача. | Контракт **never-raise** во всех функциях `merge_gate.py` и `check_branch_mergeable`: исключение → `(False, reason)`. | AC-9 |
| R-7 | **Git-операции в общем clone** `/repos/<repo>` вместо worktree → S-4-гонка параллельных задач. | Порча рабочих копий, ложные конфликты. | Все операции — в worktree ветки (`ensure_worktree`/`get_worktree_path`). | AC-8 |
| R-8 | **Defer-петля** (lease вечно busy из-за залипшего холдера) → бесконечные перепрогоны staging. | Зацикливание, расход токенов/CPU. | `merge_defer_max_attempts` + Telegram-эскалация + блокировка; реклейм lease (R-2/R-3) снимает первопричину. | AC-5, AC-11 |
| R-9 | **rebase --force-with-lease** помечает прежний approve ревьюера stale и пересоздаёт head PR. | Теоретическая потеря «зелёного» статуса PR. | На стадии `deploy` ревью повторно не проверяется; re-test в гейте — авторитетная проверка. Документировано в ADR. | AC-2 |
| R-10 | **Re-test-команда не подходит чужому репо** (раскладка enduro-trails ≠ orchestrator). | Ложный красный re-test на не-self-hosting репо. | Гейт по умолчанию реален ТОЛЬКО для `orchestrator`; прочие — no-op; `merge_retest_target` параметризует цель. | AC-12, BR-7 |
| R-11 | **Дрейф snapshot-реестра** при добавлении QG. | Красные тесты / расхождение контракта. | Обновить `_EXPECTED_QGS` (+`check_branch_mergeable`) осознанно; `_EXPECTED_TRANSITIONS` НЕ менять (стадии не трогаем). | AC-10 |
| R-12 | **Рестарт/падение прод-контейнера** `orchestrator` в рамках задачи. | Остановка конвейера всех проектов. | Не трогаем `.env*`/`docker-compose.yml`/инфру; обязательная страховка `deploy-staging` (8501). | AC-14 |
| R-13 | **Регресс существующих тестов** от изменения `advance_stage`/`gitea.handle_pr`. | Поломка конвейера. | `pytest tests/ -q` целиком зелёный; изменения аддитивны (новая ветвь на ребре, существующие пути не меняются). | AC-15 |
## Остаточные риски (принимаются)
- **Скрытый под-гейт** (merge-gate не отражён в `STAGE_TRANSITIONS`) — плата за минимальный
blast-radius; смягчён документацией (README + ADR).
- **Лишний прогон staging** при defer — переиспользование очереди вместо нового job-типа.

View File

@@ -0,0 +1,59 @@
---
type: review
work_item_id: ORCH-043
verdict: APPROVED
version: 1
---
# Review ORCH-043 — merge-gate + auto-rebase + re-test
## Summary
Реализован детерминированный (без LLM) merge-gate `check_branch_mergeable` на ребре
`deploy-staging → deploy`: догон ветки до актуального `origin/main` (`rebase` +
`push --force-with-lease` ТОЛЬКО ветки задачи), повторный прогон тестов в worktree
догнанной ветки и файловый merge-lease для сериализации слияний. Интеграция в
`stage_engine` (defer при busy-lock, rollback при конфликте/красном re-test с капом
`MAX_DEVELOPER_RETRIES`), release lease на `deploy→done` / rollback / PR-merged вебхуке.
Соответствие ТЗ (`02-trz.md`) и AC-1..AC-15 — полное. Реализация соответствует
`ADR-001-merge-gate.md` и глобальному `adr-0006`. Контракт never-raise соблюдён
во всех новых функциях, все git-операции изолированы в worktree (AC-8), `main`
никогда не пушится/форс-пушится (AC-7). Документация обновлена в этом же PR.
`pytest tests/ -q`**535 passed** (AC-15). Snapshot-реестр обновлён осознанно
(`_EXPECTED_QGS += check_branch_mergeable`, `_EXPECTED_TRANSITIONS` не тронут — AC-10).
Прод-инфра (`docker-compose*`, `.env`, `.gitea/`, `Dockerfile`) не затронута (AC-14).
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- [ ] **Двойное назначение `merge_lock_timeout_s` (300s).** Один и тот же тайм-аут
служит и порогом «лиз протух → реклейм» (crash-backstop), и фактическим окном
удержания лиза от гейта до мержа. Если deploy-деплоер по какой-то причине мержит
PR дольше 300s, ожидающая задача реклеймит лиз как stale и может пойти на слияние
параллельно — узкое окно, теоретически воспроизводящее гонку, которую закрывает
AC-5. На практике deployer мержит в начале запуска, окно мало; тайм-аут
конфигурируем. Рекомендация (не блокер): развести «возраст реклейма краша» и
«ожидаемое время удержания», либо добавить наблюдаемость (лог/алерт при
stale-реклейме непустого холдера).
- [ ] **Двойной `git fetch origin main`** — в `branch_is_behind_main` и затем в
`auto_rebase_onto_main` на пути «ветка отстаёт». Незначительная неэффективность,
не баг; можно переиспользовать результат первого fetch.
## Документация
Обновлено полностью, документация = golden source соблюдена (AC-13):
- `docs/architecture/README.md` — добавлен раздел «Merge-gate…», ветка откатов,
реестр QG (`check_branch_mergeable`), `STAGE_TRANSITIONS` корректно НЕ изменён.
- `CHANGELOG.md` — подробная запись ORCH-043.
- `.env.example` — все 7 новых `ORCH_MERGE_*` настроек с комментариями.
- ADR per-work-item `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` (Proposed)
и глобальный `docs/architecture/adr/adr-0006-merge-gate.md` + строка в `adr/README.md`.
- Тесты: `test_merge_gate.py`, `test_qg_merge_gate.py`, `test_merge_gate_race.py`,
`test_stage_engine.py::TestMergeGate`, `test_config.py`, обновлён
`test_qg_registry_snapshot.py`.

View File

@@ -0,0 +1,66 @@
---
type: test-report
work_item_id: ORCH-043
result: PASS
---
# Test Report — ORCH-043 (merge-gate + auto-rebase + re-test)
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: `feature/ORCH-043-merge-gate-auto-rebase-re-test` (HEAD `ba51aa1`)
- Дата: 2026-06-06T17:37Z
## Smoke API (read-only, прод-контейнер не трогался)
- `GET /health` → HTTP 200 `{"status":"ok","service":"orchestrator"}`
- `GET /status` → HTTP 200, активная задача ORCH-043 на стадии `testing`
- `GET /queue` → HTTP 200, breaker `closed`, preflight_ok=true, max_concurrency=1
## Результаты (test-plan 04-test-plan.yaml)
| TC ID | Описание | Модуль | Результат |
|-------|----------|--------|-----------|
| TC-01 | branch_is_behind_main → True (main ушёл вперёд) | test_merge_gate.py | PASS |
| TC-02 | branch_is_behind_main → False (ветка содержит main) | test_merge_gate.py | PASS |
| TC-03 | branch_is_behind_main never-raise | test_merge_gate.py | PASS |
| TC-04 | auto_rebase: чистый догон + push --force-with-lease | test_merge_gate.py | PASS |
| TC-05 | auto_rebase: конфликт → abort, worktree чист, main не тронут | test_merge_gate.py | PASS |
| TC-06 | auto_rebase не пушит/форс-пушит main | test_merge_gate.py | PASS |
| TC-07 | retest_branch: rc=0 → (True,'re-test green') | test_merge_gate.py | PASS |
| TC-08 | retest_branch: rc!=0 → (False) с хвостом вывода | test_merge_gate.py | PASS |
| TC-09 | retest_branch: тайм-аут → (False,'re-test timeout') | test_merge_gate.py | PASS |
| TC-10 | merge-lock: повторный захват блокируется, release в finally | test_merge_gate.py | PASS |
| TC-11 | merge-lock restart-safe: устаревший lock не блокирует | test_merge_gate.py | PASS |
| TC-12 | check_branch_mergeable: актуальна → (True,'up-to-date') | test_qg_merge_gate.py | PASS |
| TC-13 | check_branch_mergeable: отстаёт+rebase+зелёный re-test → True | test_qg_merge_gate.py | PASS |
| TC-14 | check_branch_mergeable: конфликт rebase → (False) | test_qg_merge_gate.py | PASS |
| TC-15 | check_branch_mergeable: красный re-test → (False) | test_qg_merge_gate.py | PASS |
| TC-16 | check_branch_mergeable never-raise, lock освобождён | test_qg_merge_gate.py | PASS |
| TC-17 | merge_gate_enabled=False / вне merge_gate_repos → no-op | test_qg_merge_gate.py | PASS |
| TC-18 | 'check_branch_mergeable' в QG_CHECKS и callable | test_qg_registry_snapshot.py | PASS |
| TC-19 | snapshot реестра/стадий обновлён, порядок ключей сохранён | test_qg_registry_snapshot.py | PASS |
| TC-20 | _run_qg диспетчеризует check_branch_mergeable | test_stage_engine.py | PASS |
| TC-21 | merge-gate FAIL → откат на development + Plane/Telegram | test_stage_engine.py | PASS |
| TC-22 | merge-gate FAIL уважает MAX_DEVELOPER_RETRIES | test_stage_engine.py | PASS |
| TC-23 | merge-gate PASS → продвижение к слиянию/деплою | test_stage_engine.py | PASS |
| TC-24 | сквозной сценарий гонки A/B, main остаётся зелёным | test_merge_gate_race.py | PASS |
| TC-25 | новые ORCH_* настройки: дефолты + env-override | test_config.py | PASS |
| TC-26 | полный регресс pytest tests/ зелёный | tests/ | PASS |
Целевые файлы ORCH-043 (`test_merge_gate`, `test_qg_merge_gate`, `test_merge_gate_race`,
`test_config`, `test_qg_registry_snapshot`): 33 passed; merge-gate в `test_stage_engine`: 7 passed.
## Соответствие критериям приёмки
AC-1..AC-15 — все покрыты прошедшими тестами (см. маппинг TC выше) и подтверждены
APPROVED-ревью (`12-review.md`). AC-15 (зелёный регресс) — подтверждён ниже.
## Вывод pytest
```
======================= 535 passed, 1 warning in 12.70s ========================
```
(единственное warning — PydanticDeprecatedSince20 в `src/config.py:4`, не относится к ORCH-043, нефатальное)
## Итог
PASS — 535/535 тестов зелёные, smoke API OK, прод-контейнер не затронут.
Задача готова к стадии `deploy-staging`.

View File

@@ -0,0 +1,101 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T17:44:25Z
work_item: ORCH-043
target: prod orchestrator (8500) — self-hosting
staging_gate: SUCCESS
merge_gate: SUCCESS
rebuild_required: true
restart_required: true
mode: artifact-validated; prod rebuild+restart handed off to Owner (self-hosting safeguard)
---
# Production Deploy Log — ORCH-043
`feat(merge-gate): auto-rebase onto current main + re-test + serialise merges`
## Verdict
`deploy_status: SUCCESS` — the deployable artifact is validated and ready, and the
automated deploy-stage responsibility is complete. ORCH-043 changes **runtime
`src/` code**, so the live prod rollout needs a container **rebuild + restart**.
Per the self-hosting guardrail that step is an **Owner action** (see Handoff) and was
deliberately **NOT** performed by this agent.
## Precondition: staging gate (`check_staging_status`)
`deploy` is reachable only because the staging gate passed:
- `15-staging-log.md``staging_status: SUCCESS`, **10/10 checks PASS** on the live
`orchestrator-staging` instance (8501), run inside the staging container
(ORCH-048 canon). This is the mandatory pre-prod safeguard for self-hosting
(ADR-0003 staging gate).
## Precondition: merge-gate (`check_branch_mergeable`, ORCH-043 itself)
The new merge-gate runs on the `deploy-staging → deploy` edge, before this stage:
it validates the branch against **current** `origin/main` (catch-up rebase + re-test
+ serialised merge-lease). The branch reached `deploy`, so the gate did not roll back
or defer. Note: the branch carries this same gate code — it is the first task to be
gated by its own feature (dog-fooding), which the green staging run exercised.
## Change scope (why a prod rebuild+restart IS required)
Unlike bind-mount-only changes (cf. ORCH-048), ORCH-043 modifies code that lives
**inside the prod image** and is executed by the running app:
| File | Kind | Reaches prod via |
|------|------|------------------|
| `src/merge_gate.py` | new runtime module | image rebuild |
| `src/config.py` | runtime config (merge-gate flags, retest target/timeout) | image rebuild |
| `src/db.py` | merge-lease helpers (schema-compatible, **no migration**) | image rebuild |
| `src/qg/checks.py` | new `check_branch_mergeable` gate | image rebuild |
| `src/stage_engine.py` | sub-gate dispatch on the deploy edge | image rebuild |
| `src/webhooks/gitea.py` | PR-merged → release merge-lease | image rebuild |
| `tests/*`, `docs/*` | tests + docs | n/a (not deployed) |
Because `src/` changed, the running prod process picks up ORCH-043 **only** after a
rebuild + restart of the shared prod `orchestrator` (8500).
## Deploy action
- **Prod container rebuild/restart:** required, **not performed** (guardrail: never
rebuild/restart the shared prod `orchestrator` within an ORCH task — it serves all
projects incl. enduro-trails from one instance with a shared DB/queue; an in-task
restart is a group risk for every project).
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): **not
triggered** by this agent (not explicitly instructed; reserved for the Owner per
ORCH-36 / DEPLOY_HOOK.md).
- **Effective delivery:** merge of this branch to `main` lands the source of truth;
the prod cut-over (rebuild + restart) is the documented Owner step below.
## Handoff — Owner prod cut-over (DEPLOY_HOOK.md, INFRA.md §Self-hosting)
Perform **only in a quiet window** and in this order:
1. **P-4 (BLOCKER)** — confirm `GET http://localhost:8500/status` shows **no active
tasks** before touching prod (shared instance with enduro-trails).
2. Host `git pull` on `main` under uid 1000 (`/home/slin/repos/orchestrator`).
3. Prod cut-over via the deploy hook (conscious prod override — defaults are staging):
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
The hook snapshots the previous image, runs a 60s health loop on `:8500/health`,
and **auto-rolls back** if the new container is unhealthy.
4. Post-deploy smoke: `GET /health` → `200 {"status":"ok"}`, `GET /queue` returns
counts; confirm a subsequent ORCH/ET task transitions cleanly through the new
merge-gate (no spurious defer/rollback).
## Summary
| Item | State |
|------|-------|
| Staging gate (`check_staging_status`) | SUCCESS (10/10) |
| Merge-gate (`check_branch_mergeable`) | SUCCESS (branch reached deploy) |
| DB schema migration | none (lease is schema-compatible) |
| In-task prod rebuild/restart | NOT performed (self-hosting safeguard, by design) |
| Prod cut-over | handed off to Owner (P-4 + deploy hook, prod override) |
| Deploy stage verdict | SUCCESS |

View File

@@ -0,0 +1,70 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T17:40:13Z
base_url: http://localhost:8501
mode: stub
result: 10/10 checks PASS
---
# Staging Gate Log — ORCH-043
Staging test suite completed against the live `orchestrator-staging` instance
(port 8501). **All 10/10 checks passed**, suite exit code `0`.
## Execution
Canonical invocation — run INSIDE the `orchestrator-staging` container
(ORCH-048, ADR-001) so Block A's `ORCH_STAGING=true` guard and the B6
registry-isolation check read the running instance's own process-env
(`.env.staging`):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
> Note: the host worktree environment has no `docker` CLI, so the exec was
> driven directly through the Docker Engine API over `/var/run/docker.sock`
> (equivalent to the command above — same container, same in-container env).
> Block A `A3 ORCH_STAGING=true` and B6 both PASS, confirming the suite ran
> with the live staging registry (no host-path fallback / false FAIL).
## Results
```
============================================================
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-06T17:40:13.623652+00:00
============================================================
[Block A] SMOKE
✓ PASS A1 GET /health → 200 status=ok
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience
✓ PASS A3 ORCH_STAGING=true (not prod)
[Block B] ACCESS
✓ PASS B4 Plane: sandbox project accessible [found 5 project(s), sandbox=YES]
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
[Block C] E2E (mode=stub)
✓ PASS C7 Create issue in Plane SANDBOX
✓ PASS C8 Trigger pipeline via /webhook/plane
✓ PASS C9a Branch appears in orchestrator-sandbox
✓ PASS C9b Analyst job enqueued in staging queue
[CLEANUP]
✓ PASS CLEANUP: deleted test branch, Plane issue, task + job rows
============================================================
RESULT: 10/10 checks PASS
============================================================
[docker-exec] ExitCode=0
```
Cleanup ran fully in the `finally` block — no residual test task, branch, or
job rows left on the staging stand.

View File

@@ -1,7 +0,0 @@
# Business Request: Надёжность запуска агента: preflight ловит auth+битый флаг, --effort фикс
Work Item ID: ORCH-044
## Description
TBD

View File

@@ -1,90 +0,0 @@
# 01 — Business Requirements Document (BRD)
**Work Item:** ORCH-044
**Title:** Надёжность запуска агента: preflight ловит auth+битый флаг, --effort фикс
**Приоритет:** Высокий (надёжность конвейера)
**Автор запроса:** Слава, 05.06 («почему перед стартом аналитика не прошла проверка?»)
## 1. Контекст и инцидент (05.06)
Задача **ORCH-17** застряла на стадии Analysis ~30 минут. Аналитик-агент стартовал и
мгновенно «умирал»: run-лог — **пустой файл (0 байт)**, а job в очереди оставался в
состоянии `running` (вечное зависание без сигнала).
Корневые причины (две, наложились):
1. **`claude` Not logged in** после ребилда контейнера — токен/сессия не поднялись.
2. **Флаг `--effort`** в связке с `--print --output-format json` (CLI 2.1.142) **гасил весь
stdout** — claude завершался с пустым выводом.
**Главная системная проблема:** preflight-проверка пропустила обе битые задачи в работу —
она слепа к авторизации и не ловит «битый флаг → пустой вывод».
## 2. Проблема (как есть)
- **P1. Дыра в preflight (главное).** `src/preflight.py` сознательно проверяет только
(a) `os.path.exists(CLAUDE_BIN)` и (b) `claude --version` (timeout 5s, без токенов).
Но `--version` отвечает успешно **даже когда claude НЕ залогинен** (версия — локальная
информация). Итог: `preflight=ok`, а реальный запуск падает `Not logged in`. Preflight
слеп к авторизации и пропускает заведомо нерабочие задачи в очередь.
- **P2. `--effort` ломает вывод.** Флаг `--effort <low..max>` совместно с
`--print`/`--output-format json` в CLI 2.1.142 даёт **пустой stdout** — агент молча
умирает. Сейчас effort **отключён в проде** хотфиксом (`.env`: `ORCH_AGENT_EFFORT_*=""`),
но дефолты в `src/config.py` всё ещё `high`/`medium`, а документация (INFRA.md,
internals.md, ORCH-41) описывает effort как рабочую фичу. Несоответствие кода/доков/прода.
- **P3. Пустой лог ≠ провал.** Агент с пустым run-логом (0 байт) и `exit 0` трактуется как
**успех** (`_finalize_job``done`, авто-advance стадии) либо вечно висит `running`.
Ни watchdog, ни ретрай не срабатывают. Нет сигнала об инциденте.
## 3. Бизнес-последствия
- Любой сбой авторизации или несовместимости флага → **тихое зависание** задачи без алерта.
- Блокируется конвейер **всех** проектов (общий инстанс/очередь, self-hosting) — как было с
ORCH-17 (30 мин простоя, ручное вмешательство).
- Деградация доверия к автономности оркестратора: «проверка перед стартом» не работает.
## 4. Цель
Сделать запуск агента **отказоустойчивым по входу и по выходу**:
1. Preflight ловит отсутствие/протухание авторизации **дёшево и без траты токенов** до того,
как job будет заклеймлен.
2. Разобраться с `--effort` и привести код/доки/прод к одному непротиворечивому состоянию.
3. Пустой/невалидный результат запуска трактуется как **провал** (job → `failed`), чтобы
сработали watchdog/ретрай и алерт, а не вечное зависание.
## 5. Заинтересованные стороны
- **Owner/Слава** — инициатор, требует «проверки перед стартом».
- **Все проекты на инстансе** (enduro-trails и self-hosting ORCH) — страдают от простоя.
- **Агенты конвейера** — analyst/architect/... — все запускаются через единый launcher.
## 6. Объём (Scope)
**В объёме:**
- Дешёвая token-free проверка авторизации в preflight.
- Расследование и решение по `--effort` (вернуть корректно ИЛИ задокументировать как
unsupported и убрать из кода/дефолтов/доков).
- Детекция «пустой лог / нет валидного result-JSON» как провала job с корректным
переводом в `failed` и срабатыванием ретрая/алерта.
- Обновление документации (INFRA.md / internals.md / CHANGELOG) в том же PR.
**Вне объёма:**
- Prompt-ping (ping→pong) — **запрещено** (жжёт rate limit). Только локальные/дешёвые проверки.
- Реформа circuit breaker / backoff-логики (используем существующие механизмы).
- Изменение схемы стадий/конвейера.
- Автоматический re-login claude (восстановление авторизации) — отдельная задача.
## 7. Бизнес-правила
- BR-1: Preflight **не тратит токены** и не делает сетевых вызовов к API модели.
- BR-2: Протухшая/нечитаемая авторизация → `preflight=fail` → job **не клеймится** (остаётся
`queued`), пишется warning, при необходимости — алерт/брейкер.
- BR-3: Пустой run-лог ИЛИ отсутствие валидного result-JSON при `exit 0` → job `failed`
(никогда не `done` и не вечный `running`).
- BR-4: Никаких `--no-verify`/обхода хуков без явного одобрения Owner.
- BR-5: Код, дефолты `config.py`, прод `.env` и документация по `--effort` должны быть
взаимно непротиворечивы после задачи.
## 8. Критерии успеха (бизнес-уровень)
- Симуляция «не залогинен» → preflight ловит до клейма, job не стартует впустую.
- Симуляция «пустой лог + exit 0» → job становится `failed`, срабатывает ретрай/алерт.
- Состояние `--effort` однозначно: либо работает с json-форматом, либо удалён из активного
пути и доков (без «мёртвого» флага в дефолтах).
- Инцидент класса ORCH-17 больше не приводит к тихому 30-минутному зависанию.
## 9. Связанные материалы
- `src/preflight.py`, `src/queue_worker.py`, `src/agents/launcher.py`, `src/config.py`
- `docs/history/LESSONS_ORCH-017.md`, `docs/history/LESSONS_2026-06-05.md`
- ORCH-41 (effort/model resolver), ORCH-1 (очередь/resilience), ORCH-7 (watchdog)

View File

@@ -1,143 +0,0 @@
# 02 — Техническое задание (ТЗ)
**Work Item:** ORCH-044
**Основано на:** 01-brd.md
> Примечание: ТЗ фиксирует **что** должно измениться и **наблюдаемое поведение**.
> Выбор конкретной реализации (например, формат проверки `.credentials.json` vs парсинг
> маркера в логе) — за архитектором (стадия architecture, ADR). Где описаны варианты —
> это границы допустимого решения, а не предписание.
> ## ⛔ КОРРЕКЦИЯ SCOPE ВЛАДЕЛЬЦЕМ (Слава, 06.06) — ЧИТАТЬ ПЕРВЫМ
>
> **P2 (`--effort`) ПОЛНОСТЬЮ ИСКЛЮЧЁН из этой задачи.** Решение владельца:
> - effort **НУЖЕН и работает** — его **НЕЛЬЗЯ** убирать как unsupported. **Вариант B запрещён.**
> - В ORCH-044 **НЕ трогать** `--effort`: ни `_spawn` effort_flag, ни `resolve_agent_effort`, ни дефолты `agent_effort_*` в `config.py`, ни ORCH-41 effort-доки.
> - Текущий прод-хотфикс `ORCH_AGENT_EFFORT_*=""` в `.env` **оставить как есть** — не снимать, не менять.
> - Полноценный возврат effort (расследование флагов + json) вынесен в **ОТДЕЛЬНУЮ задачу ORCH-50** («Эффорт агентов: заставить --effort работать с --print/json»). Туда же — любое расследование причины пустого stdout.
>
> **Архитектор/дев игнорируют все TR-2.x и AC-7/AC-8/AC-9, относящиеся к effort.** Реализуем ТОЛЬКО:
> - **P1** — preflight ловит auth (ОБА подхода: проактивно cred-файл `expiresAt` + постфактум маркер `Not logged in`);
> - **P3** — пустой лог / нет result-JSON ⇒ job `failed` (не `done`, не вечный `running`).
>
> Заголовок задачи содержит «--effort фикс» по историческим причинам — это НЕ часть scope. Effort = ORCH-50.
## 1. Задействованные модули `src/`
| Модуль | Текущее место | Изменение |
|--------|---------------|-----------|
| `src/preflight.py` | `_run_version`, `_compute`, `check` | Добавить дешёвую token-free проверку авторизации (P1) |
| `src/config.py` | блок ORCH-41 effort (стр. 98108), новый блок настроек preflight-auth | Настройки auth-проверки; решение по effort-дефолтам (P2) |
| `src/agents/launcher.py` | `_spawn` (effort_flag, стр. 290292, 303311), `_monitor_agent` (стр. 460615), `_finalize_job` (стр. 630667) | Решение по `--effort` (P2); детекция пустого лога / отсутствия result-JSON (P3) |
| `src/queue_worker.py` | `_drain_once` claim-gating (стр. 158165) | Учесть новый auth-fail preflight в гейтинге клейма (P1) — при необходимости |
| `src/db.py` | `mark_job` | Использование существующего перевода job → `failed` (P3); новых колонок не требуется |
Новых файлов модулей не предполагается обязательно; допускается выделение хелпера
(например, `_check_auth()` в `preflight.py`) — на усмотрение архитектора.
## 2. Требования по проблемам
### P1 — Preflight ловит авторизацию (token-free)
- **TR-1.1.** Preflight ДОЛЖЕН, помимо `os.path.exists(bin)` и `claude --version`, выполнять
**дешёвую проверку авторизации без обращения к API модели и без prompt-ping**.
- **TR-1.2.** Допустимые подходы (выбор — за архитектором, ADR):
- (a) Проверка существования и читаемости файла учётных данных
`~/.claude/.credentials.json` (HOME агента — `/home/slin`, см. launcher env, стр. 326)
и валидности OAuth-токена по дате истечения внутри
(`claudeAiOauth.expiresAt`, epoch ms) — `expiresAt <= now` ⇒ протух ⇒ fail;
- (b) Парсинг реального run-вывода на маркер `Not logged in` (и подобные) с переводом
job в провал и размыканием/учётом circuit breaker.
- Подход (a) предпочтителен как **проактивный** (ловит ДО клейма); (b) — как защитная
сетка постфактум. Допускается комбинация.
- **TR-1.3.** Путь к файлу учётных данных ДОЛЖЕН резолвиться согласованно с тем HOME,
под которым launcher реально спавнит claude (`/home/slin`), а не из окружения процесса
оркестратора (аналогично тому, как `_claude_bin()` следует за реально исполняемым путём).
- **TR-1.4.** Результат auth-проверки кешируется тем же механизмом, что и version-check
(`preflight_cache_ttl`), чтобы не читать файл на каждый тик воркера.
- **TR-1.5.** При `auth=fail`: `check()` возвращает `(False, reason)` с **информативным
reason** (например, `claude not logged in: credentials missing` / `OAuth token expired at
<iso>`). Job НЕ клеймится (поведение `_drain_once` уже корректно при `ok=False`).
- **TR-1.6.** Граница ответственности: preflight остаётся **локальным** (BR-1). Сетевая
валидация токена у провайдера — вне объёма.
- **TR-1.7.** Поведение при «всё хорошо» не меняется: залогинен + валидный токен ⇒ `ok=True`.
### P2 — Решение по `--effort`
- **TR-2.1.** Провести расследование (стадия architecture/development): причина пустого
stdout при `--effort` + `--print --output-format json` в CLI 2.1.142 — несовместимость
с json-форматом, иной синтаксис флага, или баг CLI. Зафиксировать вывод в ADR/`10-tech-risks.md`.
- **TR-2.2.** По итогам выбрать **ровно один** исход и привести к нему код+доки+дефолты:
- **Вариант A (вернуть effort):** найден корректный способ (например, иной синтаксис или
несовместимость только с конкретным output-format) — `--effort` снова формируется в
`_spawn` корректно; прод-хотфикс `ORCH_AGENT_EFFORT_*=""` снимается; добавить
регресс-тест, что вывод не пустой.
- **Вариант B (unsupported):** effort несовместим — **убрать `--effort` из активного пути
запуска** (`_spawn` не формирует `effort_flag`), убрать/нейтрализовать дефолты effort в
`config.py`, обновить ORCH-41-доки (INFRA.md, internals.md) пометив фичу как unsupported
на данной версии CLI. `resolve_agent_effort` либо удаляется, либо документированно
оставляется заглушкой (решение — ADR).
- **TR-2.3.** Независимо от A/B: **не должно остаться «мёртвого» флага**, который тихо гасит
вывод. После задачи запуск с дефолтной конфигурацией прода ДОЛЖЕН давать непустой
result-JSON.
- **TR-2.4.** Изменение дефолтов/удаление флага не должно ломать `resolve_agent_model`
(модель — независимая фича ORCH-41) и существующие тесты `test_resolve_agent_effort.py`
(их допустимо обновить под новый контракт).
### P3 — Пустой лог / нет result-JSON ⇒ провал
- **TR-3.1.** В `_monitor_agent`/`_finalize_job`: при `exit_code == 0` ДОЛЖНА выполняться
**проверка валидности результата** перед тем как считать job успешным:
- run-лог **непустой** (размер > 0 и/или содержит непустой текст), И
- из него извлекается **валидный result-JSON** (тот же контракт, что использует
`usage._extract_last_json_object` / `parse_usage_from_log`).
- **TR-3.2.** Если результат невалиден (пустой лог ИЛИ нет валидного JSON) при `exit_code==0`,
job ДОЛЖЕН трактоваться как **провал**:
- НЕ переводиться в `done`;
- попасть в путь ретрая/провала (`attempts < max_attempts` ⇒ requeue, иначе `failed`),
аналогично permanent-ветке `_finalize_permanent`, с информативным `error`
(например, `empty run log / no result JSON (run_id=...)`);
- сгенерировать алерт (Telegram), как прочие провалы;
- НЕ выполнять авто-advance стадии (`_try_advance_stage`) и НЕ постить «успешный»
status-коммент.
- **TR-3.3.** Классификация такого провала: по умолчанию — **permanent** (это не 429/overload).
Если в логе присутствует transient-маркер (через `error_classifier`) — допускается
transient-путь. Auth-провал (`Not logged in`) — на усмотрение архитектора: может
маршрутизироваться как сигнал брейкеру (P1/TR-1.2b).
- **TR-3.4.** Никогда не оставлять job в `running` навечно из-за пустого результата: либо
`done` (валидно), либо `failed`/`queued`(retry). (Watchdog ORCH-7 продолжает закрывать
случай таймаута; здесь закрывается случай «быстрая смерть с exit 0».)
- **TR-3.5.** Защитность: вся проверка обёрнута так, что её собственная ошибка не роняет
монитор (как и прочий код `_monitor_agent`); при сомнении — fail-safe в сторону провала job.
## 3. Изменения API
Нет новых/изменённых HTTP-endpoint'ов. Допускается обогащение поля `preflight_reason` в
`/queue` (через существующий `worker.status()` / `QueueWorker.last_preflight_reason`) более
информативным auth-сообщением — без изменения схемы ответа.
## 4. Изменения схемы БД
Нет. Используются существующие колонки `jobs` (`status`, `error`, `attempts`,
`max_attempts`, `transient_attempts`) и `agent_runs`. Новых таблиц/колонок не требуется.
## 5. Требования к новым QG checks
Новых Quality Gate проверок не требуется — изменения в слое запуска/preflight, не в гейтах
стадий. Реестр `QG_CHECKS` не меняется.
## 6. Конфигурация (env / config.py)
- Возможные новые настройки preflight-auth (имена — на усмотрение архитектора), например:
- `ORCH_PREFLIGHT_CHECK_AUTH` (bool, default true) — включение auth-проверки;
- путь к credentials, если не выводится из HOME автоматически.
- Решение по effort-дефолтам (`agent_effort_*`) согласно TR-2.2 (нейтрализовать при варианте B).
- Все новые настройки документируются в `config.py` docstring и в INFRA.md (env-карта).
## 7. Артефакты pipeline (обязательны к созданию/обновлению)
- `06-adr/ADR-NNN-*.md` — решение по подходу preflight-auth (a/b/комбо) и по effort (A/B).
- `10-tech-risks.md` — риск ложноположительной auth-проверки, риск регрессии effort, риск
fail-safe-провала на легитимных пустых выводах.
- `12-review.md`, `13-test-report.md` — по стадиям.
- Обновить `docs/operations/INFRA.md` и `docs/architecture/internals.md` (effort-секции),
`CHANGELOG.md`. Документация = golden source (правило агентов №2).
## 8. Ограничения и запреты
- ❌ Prompt-ping в preflight (жжёт rate limit) — запрещено (BR-1, комментарий в preflight.py).
- ❌ Сетевые вызовы к API модели в preflight.
- ❌ Оставлять job в `running` без таймаута при пустом результате.
-`--no-verify`/обход хуков без одобрения Owner.
- ⚠️ Self-hosting: не ронять прод-контейнер `orchestrator`; проверка изменений — через
staging (8501) перед прод-деплоем (см. CLAUDE.md, INFRA.md).

View File

@@ -1,122 +0,0 @@
# 03 — Критерии приёмки (Acceptance Criteria)
**Work Item:** ORCH-044
Каждый критерий — однозначное PASS/FAIL. Привязка к TR из `02-trz.md`.
## P1 — Preflight ловит авторизацию
### AC-1 — Не залогинен ⇒ preflight FAIL (TR-1.1, TR-1.2, TR-1.5)
- **Дано:** бинарь claude существует, `claude --version` отвечает успешно, НО учётные
данные отсутствуют/нечитаемы (нет `.credentials.json`).
- **Когда:** вызывается `preflight.check(force=True)`.
- **Тогда:** возвращается `(False, reason)`, где `reason` упоминает авторизацию
(например, «not logged in» / «credentials»).
- **FAIL если:** возвращается `(True, ...)` (как сейчас — слепота к auth).
### AC-2 — Протухший OAuth-токен ⇒ preflight FAIL (TR-1.2a)
- **Дано:** `.credentials.json` существует и читаем, но `claudeAiOauth.expiresAt` в прошлом.
- **Когда:** `preflight.check(force=True)`.
- **Тогда:** `(False, reason)` с указанием на истечение токена.
- *(N/A, если архитектор выбрал чистый вариант (b) без чтения файла — тогда покрывается AC-9.)*
### AC-3 — Валидный логин ⇒ preflight OK без регрессии (TR-1.7)
- **Дано:** bin есть, `--version` ок, `.credentials.json` читаем, `expiresAt` в будущем.
- **Когда:** `preflight.check(force=True)`.
- **Тогда:** `(True, ...)`.
- **FAIL если:** залогиненный валидный кейс даёт FAIL (ложное срабатывание).
### AC-4 — Auth-fail блокирует клейм job (TR-1.5, BR-2)
- **Дано:** preflight возвращает `(False, ...)` из-за auth; в очереди есть `queued` job.
- **Когда:** `QueueWorker._drain_once()` выполняет тик.
- **Тогда:** job **не клеймится** (остаётся `queued`), в `worker.last_preflight_ok=False`,
пишется лог-warning; claude не спавнится.
- **FAIL если:** job переходит в `running` / спавнится агент.
### AC-5 — Token-free и локально (BR-1, TR-1.6)
- **Дано:** auth-проверка.
- **Тогда:** она НЕ делает prompt-ping и НЕ обращается к API модели (никаких httpx/сетевых
вызовов к провайдеру в пути проверки; проверяется по коду/моку — сетевой вызов не
происходит).
- **FAIL если:** проверка отправляет запрос к модели/жжёт токены.
### AC-6 — Кеширование auth-проверки (TR-1.4)
- **Дано:** `preflight_cache_ttl` > 0, первый `check()` выполнен.
- **Когда:** повторные `check()` в пределах TTL.
- **Тогда:** дорогая часть (чтение файла/процесс) не повторяется чаще TTL (как у version-check).
- **FAIL если:** файл/процесс дёргается на каждый тик внутри TTL.
## P2 — Решение по `--effort`
> ⛔ **ИСКЛЮЧЕНО ВЛАДЕЛЬЦЕМ (06.06):** AC-7, AC-8, AC-9 НЕ применяются в ORCH-044. effort не трогаем, вынесен в ORCH-50. См. коррекцию scope в 02-trz.md.
### AC-7 — Расследование задокументировано (TR-2.1)
- **Тогда:** в ADR (`06-adr/`) и/или `10-tech-risks.md` зафиксирована причина пустого stdout
при `--effort` + `--print --output-format json` (несовместимость/синтаксис/баг CLI).
- **FAIL если:** изменения внесены без объяснения первопричины.
### AC-8 — Однозначный исход A или B, без «мёртвого» флага (TR-2.2, TR-2.3)
- **Тогда:** реализован ровно один из вариантов:
- **A:** `--effort` формируется и запуск с ним даёт **непустой** result-JSON; прод-хотфикс
`ORCH_AGENT_EFFORT_*=""` более не требуется; есть регресс-тест на непустой вывод; ИЛИ
- **B:** `--effort` **не формируется** в активном пути `_spawn`; дефолты `agent_effort_*`
нейтрализованы; ORCH-41-доки помечают effort как unsupported на текущем CLI.
- **FAIL если:** в коде остаётся путь, где дефолтная конфигурация добавляет `--effort` и
гасит вывод; ИЛИ код/доки/дефолты противоречат друг другу.
### AC-9 — Дефолтный запуск даёт непустой результат (TR-2.3, перекликается с P3)
- **Дано:** конфигурация по умолчанию после задачи (без ручного хотфикса в `.env`).
- **Когда:** агент запускается стандартным путём `_spawn`.
- **Тогда:** результат запуска — непустой run-лог с валидным result-JSON (проверяемо
модульно через построение cmd и/или интеграционно на моке claude).
- **FAIL если:** дефолтный путь воспроизводит пустой stdout инцидента.
## P3 — Пустой лог / нет result-JSON ⇒ провал
### AC-10 — Пустой лог + exit 0 ⇒ job НЕ done (TR-3.1, TR-3.2)
- **Дано:** агент завершился `exit_code=0`, но run-лог пустой (0 байт).
- **Когда:** отрабатывает `_monitor_agent`/`_finalize_job`.
- **Тогда:** job НЕ переходит в `done`; переходит в `failed` (или `queued` при наличии
retry-бюджета) с информативным `error`; шлётся алерт.
- **FAIL если:** job становится `done`, либо остаётся `running` навсегда.
### AC-11 — Нет валидного result-JSON + exit 0 ⇒ job НЕ done (TR-3.1, TR-3.2)
- **Дано:** run-лог непустой, но не содержит валидного result-JSON (мусор/обрезок).
- **Когда:** финализация job.
- **Тогда:** job трактуется как провал (как AC-10).
- **FAIL если:** job становится `done`.
### AC-12 — Нет авто-advance и нет «успешного» коммента при провале результата (TR-3.2)
- **Дано:** кейс AC-10/AC-11.
- **Тогда:** `_try_advance_stage` НЕ вызывается (стадия не двигается), «успешный»
status-коммент агента НЕ постится.
- **FAIL если:** стадия продвинулась/запостился успех при пустом результате.
### AC-13 — Валидный результат не регрессирует (TR-3.1)
- **Дано:** `exit_code=0` и непустой run-лог с валидным result-JSON.
- **Когда:** финализация job.
- **Тогда:** job → `done`, авто-advance и usage-коммент работают как раньше.
- **FAIL если:** легитимный успешный запуск теперь ошибочно помечается провалом.
### AC-14 — Никогда не вечный `running` (TR-3.4, BR-3)
- **Тогда:** для любого завершившегося процесса (любой exit_code, включая 0 с пустым логом)
job завершается в терминальном/ретраябельном состоянии (`done`/`failed`/`queued`), не
остаётся `running`.
- **FAIL если:** существует путь, оставляющий job `running` после выхода процесса.
## Сквозные
### AC-15 — Документация обновлена в том же PR (правило агентов №2, №6)
- **Тогда:** обновлены `docs/operations/INFRA.md` (env-карта preflight-auth и/или effort),
`docs/architecture/internals.md` (effort-секция), `CHANGELOG.md`; заведён ADR.
- **FAIL если:** функционал изменён, доки/CHANGELOG/ADR не обновлены (reviewer → REQUEST_CHANGES).
### AC-16 — Тесты зелёные (test-plan)
- **Тогда:** все тесты из `04-test-plan.yaml` проходят; `pytest tests/ -q` зелёный.
- **FAIL если:** хотя бы один тест плана FAIL или существующие тесты сломаны без обоснованного
обновления контракта.
### AC-17 — Self-hosting безопасность (CLAUDE.md)
- **Тогда:** изменения не требуют рестарта/падения прод-контейнера `orchestrator` в рамках
задачи; проверка прошла через staging (8501).
- **FAIL если:** задача ломает/рестартует прод-инстанс, останавливая конвейер других проектов.

View File

@@ -1,145 +0,0 @@
work_item: ORCH-044
title: "Надёжность запуска агента: preflight auth + --effort фикс + пустой лог = провал"
notes: >
Реальный claude/Popen НЕ спавнится: subprocess и launcher мокаются (паттерн
tests/test_resilience.py). БД — свежий per-test sqlite (fixture fresh_db).
Файлы учётных данных создаются во временном каталоге (tmp_path) и путь
мокается. Сетевые вызовы запрещены — проверяются моками/отсутствием httpx.
tests:
# ---------------- P1: preflight ловит авторизацию ----------------
- id: TC-01
type: unit
description: "Нет .credentials.json при рабочем --version -> preflight.check() = (False, reason про auth)"
module: tests/test_preflight_auth.py
covers: [AC-1, TR-1.1, TR-1.2]
expected: PASS
- id: TC-02
type: unit
description: "Протухший OAuth (claudeAiOauth.expiresAt в прошлом) -> preflight FAIL про истечение токена"
module: tests/test_preflight_auth.py
covers: [AC-2, TR-1.2a]
expected: PASS
- id: TC-03
type: unit
description: "Валидный логин (credentials читаемы, expiresAt в будущем) -> preflight OK, без регрессии"
module: tests/test_preflight_auth.py
covers: [AC-3, TR-1.7]
expected: PASS
- id: TC-04
type: unit
description: "Нечитаемый/битый .credentials.json (невалидный JSON) -> preflight FAIL, не падает исключением"
module: tests/test_preflight_auth.py
covers: [AC-1, TR-1.2a, TR-3.5]
expected: PASS
- id: TC-05
type: unit
description: "Auth-проверка token-free: при check() не происходит сетевого вызова к API модели (мок httpx/urlopen не вызван)"
module: tests/test_preflight_auth.py
covers: [AC-5, BR-1, TR-1.6]
expected: PASS
- id: TC-06
type: unit
description: "Auth-результат кешируется: повторные check() в пределах preflight_cache_ttl не перечитывают credentials"
module: tests/test_preflight_auth.py
covers: [AC-6, TR-1.4]
expected: PASS
- id: TC-07
type: unit
description: "Путь к credentials резолвится от HOME агента (/home/slin), а не от окружения процесса оркестратора"
module: tests/test_preflight_auth.py
covers: [TR-1.3]
expected: PASS
- id: TC-08
type: integration
description: "QueueWorker._drain_once при preflight auth-fail не клеймит job: job остаётся queued, claude не спавнится, last_preflight_ok=False"
module: tests/test_preflight_auth.py
covers: [AC-4, BR-2, TR-1.5]
expected: PASS
# ---------------- P2: решение по --effort ----------------
- id: TC-09
type: unit
description: "Вариант B: при дефолтной конфигурации построенная cmd в _spawn НЕ содержит '--effort' (флаг не гасит вывод). При варианте A — тест адаптируется на корректное формирование effort"
module: tests/test_effort_flag.py
covers: [AC-8, TR-2.2, TR-2.3]
expected: PASS
- id: TC-10
type: unit
description: "resolve_agent_effort согласован с принятым решением (B: нейтрализован/пусто по дефолту; A: валидное значение). Существующий test_resolve_agent_effort обновлён под новый контракт и зелёный"
module: tests/test_resolve_agent_effort.py
covers: [AC-8, TR-2.4]
expected: PASS
- id: TC-11
type: integration
description: "Дефолтный путь запуска (мок claude, отдающий валидный result-JSON) даёт непустой лог с валидным JSON — воспроизведение инцидента (пустой stdout) не происходит"
module: tests/test_effort_flag.py
covers: [AC-9, TR-2.3]
expected: PASS
# ---------------- P3: пустой лог / нет result-JSON = провал ----------------
- id: TC-12
type: integration
description: "exit_code=0 + пустой run-лог (0 байт) -> job НЕ done; помечается failed (или queued при retry-бюджете) с информативным error; алерт вызван"
module: tests/test_empty_log_failure.py
covers: [AC-10, TR-3.1, TR-3.2]
expected: PASS
- id: TC-13
type: integration
description: "exit_code=0 + лог без валидного result-JSON (мусор) -> job трактуется как провал, не done"
module: tests/test_empty_log_failure.py
covers: [AC-11, TR-3.1]
expected: PASS
- id: TC-14
type: integration
description: "При провале по пустому результату _try_advance_stage НЕ вызывается и успешный usage-коммент НЕ постится"
module: tests/test_empty_log_failure.py
covers: [AC-12, TR-3.2]
expected: PASS
- id: TC-15
type: integration
description: "exit_code=0 + непустой лог с валидным result-JSON -> job done, авто-advance и usage-коммент работают (нет регрессии)"
module: tests/test_empty_log_failure.py
covers: [AC-13, TR-3.1]
expected: PASS
- id: TC-16
type: integration
description: "Любой выход процесса не оставляет job в 'running': пустой лог+exit0 завершается терминально (done/failed/queued)"
module: tests/test_empty_log_failure.py
covers: [AC-14, BR-3, TR-3.4]
expected: PASS
- id: TC-17
type: unit
description: "Классификация пустого результата по умолчанию permanent; transient-маркер в логе уводит в transient-путь (error_classifier)"
module: tests/test_empty_log_failure.py
covers: [TR-3.3]
expected: PASS
# ---------------- Регрессия / сквозное ----------------
- id: TC-18
type: unit
description: "Регресс: существующие preflight-кейсы (bin missing, --version ok) из test_resilience.py остаются зелёными после добавления auth-слоя"
module: tests/test_resilience.py
covers: [AC-3, TR-1.7]
expected: PASS
- id: TC-19
type: integration
description: "Полный прогон 'pytest tests/ -q' зелёный — ни один существующий тест не сломан без обоснованного обновления контракта"
module: tests/
covers: [AC-16]
expected: PASS

View File

@@ -1,168 +0,0 @@
# ADR-001: Token-free auth-preflight + «пустой результат = провал» в запуске агента
**Work Item:** ORCH-044
**Статус:** Accepted
**Дата:** 2026-06-06
**Автор:** Architect
> ⛔ **Scope (коррекция владельца, 06.06):** `--effort` (P2) **исключён** из ORCH-044 и
> вынесен в **ORCH-50**. Этот ADR покрывает только **P1** (preflight ловит авторизацию)
> и **P3** (пустой лог / нет result-JSON ⇒ job `failed`). Любые решения по effort,
> дефолтам `agent_effort_*` и ORCH-41 effort-докам — **вне этого ADR**.
---
## Контекст
Инцидент 05.06 (ORCH-17): аналитик-агент стартовал и мгновенно «умирал» — run-лог пустой
(0 байт), job в очереди завис в `running`. Две наложившиеся причины: (1) `claude Not logged
in` после ребилда контейнера; (2) `--effort` гасил stdout. **Системная проблема:**
preflight пропустил заведомо нерабочую задачу в работу, а пустой результат был неотличим
от успеха. Поскольку инстанс общий для всех проектов (self-hosting, общая очередь/БД),
тихое зависание блокирует конвейер **всех** проектов.
Текущее состояние слоя запуска:
- `src/preflight.py` проверяет только `os.path.exists(bin)` и `claude --version`. `--version`
отвечает успешно **даже когда claude не залогинен** (версия — локальная информация) ⇒
preflight слеп к авторизации.
- `src/agents/launcher.py::_monitor_agent` трактует `exit_code == 0` как успех **независимо
от формы stdout** (комментарий в `_spawn`, стр. 302) ⇒ пустой лог + exit 0 → `done` +
авто-advance стадии.
Ограничения (BR-1): preflight обязан быть **локальным и token-free** — никакого prompt-ping
и сетевых вызовов к API модели.
## Решение
### P1 — Preflight ловит авторизацию (комбинация проактивной и постфактум-проверок)
Реализуем **оба** подхода из TR-1.2 (a + b), проактивный — основной гейт, постфактум —
защитная сетка.
**(a) Проактивно — чтение файла учётных данных (основной гейт).**
`preflight._compute()` после успешного `--version` выполняет `_check_auth()`:
1. Резолвит путь к credentials **согласованно с HOME, под которым launcher реально спавнит
claude** (`/home/slin`), а НЕ из окружения процесса оркестратора. Реализуется зеркально
`_claude_bin()`: новый `_agent_home()` читает `AgentLauncher.AGENT_HOME` (новая константа,
значение `/home/slin`), путь = `settings.claude_credentials_path` если задан, иначе
`<AGENT_HOME>/.claude/.credentials.json`.
2. Файла нет / нечитаем / невалидный JSON ⇒ `(False, "claude not logged in: credentials …")`.
3. Нет блока `claudeAiOauth` / accessToken ⇒ `(False, "not logged in: no oauth token")`.
4. `claudeAiOauth.expiresAt` (epoch **ms**) `<= now_ms (+ skew)`
`(False, "OAuth token expired at <iso>")`.
5. accessToken есть, но `expiresAt` отсутствует/не число ⇒ **OK** (нельзя доказать истечение;
не плодим ложные срабатывания — см. Риски).
6. Иначе ⇒ `(True, "auth ok")`.
`_check_auth()` **никогда не бросает**: любое исключение → `(False, "auth check error: …")`
(fail-safe в сторону «не клеймить», BR-2 / TR-3.5).
Кеширование (TR-1.4 / AC-6): чтение файла встроено в `_compute()`, который уже кешируется
`check()` на `preflight_cache_ttl`. **Отдельный кеш не вводится** — auth-чтение происходит
только на cache-miss, как и `--version`.
Гейтинг клейма (TR-1.5 / AC-4 / BR-2): **изменений в `queue_worker._drain_once` не требуется**
— он уже не клеймит job при `ok=False`. Информативный auth-reason автоматически попадает в
`worker.last_preflight_reason` и `/queue` (без изменения схемы ответа).
**(b) Постфактум — маркер `Not logged in` в run-логе (защитная сетка).**
Если агент всё-таки стартовал при протухшей сессии (гонка: токен истёк между preflight и
спавном), `launcher` при финализации детектит auth-маркер в логе
(`preflight.is_auth_failure_text(text)`: «not logged in», «please run /login»,
«unauthorized», «401») и:
- включает маркер в `error` job;
- вызывает `preflight.reset_cache()`, чтобы **следующий тик воркера переоценил auth
проактивно** (быстрый подхват re-login ИЛИ дальнейшее гейтирование, если всё ещё битый).
Auth-провал **не** маршрутизируется как transient (это не 429) и **не** крутит брейкер —
правильный механизм гейтирования здесь preflight, а не circuit breaker.
### P3 — Пустой лог / нет result-JSON ⇒ провал job
В `_monitor_agent` для ветки `exit_code == 0` вводим **валидацию результата** перед тем как
считать job успешным. Новый защитный хелпер `_validate_result(output_path) -> (ok, reason)`:
- лог отсутствует / пустой (size 0 или только whitespace) ⇒ невалиден;
- иначе извлекаем result-JSON **тем же контрактом**, что usage-учёт
(`usage._extract_last_json_object` / `parse_usage_from_text`); нет валидного объекта ⇒
невалиден;
- хелпер обёрнут try/except и **не роняет монитор**; при собственной ошибке —
fail-safe в сторону провала (TR-3.5).
`success = (exit_code == 0 and result_ok)`. Побочные эффекты успеха выполняются **только при
`success`**:
- `_post_usage_comments(...)` (успешный status-коммент) — **не** постится при невалидном
результате (AC-12);
- `_try_advance_stage(...)`**не** вызывается при невалидном результате (AC-12);
- при `exit_code == 0 and not result_ok` шлётся Telegram-алерт о «пустом/невалидном
результате».
Финализация job (`_finalize_job` получает новый флаг `result_ok`):
- `exit_code == 0 and result_ok``done` (как раньше, AC-13 — без регрессии);
- `exit_code != 0` **ИЛИ** `result_ok == False` ⇒ путь провала:
- классификация лога `error_classifier.classify_log_file` (по умолчанию **permanent**;
transient-маркер уводит в transient-путь — TR-3.3);
- permanent: `attempts < max_attempts` ⇒ requeue (`queued`), иначе `failed` + алерт;
- `error` информативен: `empty run log / no result JSON (run_id=…)` для случая пустого
результата.
Реальный `exit_code` по-прежнему пишется в `agent_runs` без искажения; на решение
done/fail влияет отдельный флаг `result_ok`, а не подменённый код выхода.
`exit_code == 0` теперь **всегда** завершается терминально/ретраябельно (`done` |
`failed` | `queued`) — путь «быстрая смерть с exit 0 → вечный running» закрыт (AC-14, BR-3).
Watchdog ORCH-7 продолжает закрывать таймауты.
### Конфигурация (config.py)
| Настройка | Env | Default | Назначение |
|-----------|-----|---------|------------|
| `preflight_check_auth` | `ORCH_PREFLIGHT_CHECK_AUTH` | `True` | Вкл/выкл auth-проверку (аварийный тумблер) |
| `claude_credentials_path` | `ORCH_CLAUDE_CREDENTIALS_PATH` | `""` | Явный путь; пусто ⇒ `<AGENT_HOME>/.claude/.credentials.json` |
| `auth_expiry_skew_seconds` | `ORCH_AUTH_EXPIRY_SKEW_SECONDS` | `0` | Запас на рассинхрон часов при сравнении `expiresAt` |
`agent_effort_*` дефолты и `--effort` в `_spawn`**не трогаем** (scope, ORCH-50).
## Альтернативы
- **A1. Prompt-ping (ping→pong) для проверки auth.** ❌ Запрещено BR-1 (жжёт rate limit,
латентность). Отвергнуто.
- **A2. Только постфактум-маркер (чистый вариант b).** Ловит auth лишь ПОСЛЕ спавна и траты
цикла; не гейтирует клейм. Оставлен как защитная сетка, но не как основной механизм.
- **A3. Сетевая валидация токена у провайдера.** Нарушает «preflight локальный» (TR-1.6),
добавляет сетевую зависимость в горячий путь воркера. Отвергнуто.
- **A4. Подменять exit_code на ненулевой при пустом результате.** Исказило бы
`agent_runs.exit_code` и классификацию. Выбрали отдельный флаг `result_ok`.
- **A5. Отдельный кеш для auth.** Избыточно — `_compute()` уже под общим TTL.
## Последствия
**Плюсы.**
- Заведомо нерабочая (не залогинен / протухший токен) задача **не клеймится** — экономия
цикла и отсутствие тихого зависания.
- Пустая «быстрая смерть» агента теперь видима: `failed`/retry + алерт вместо ложного `done`
и движения стадии вперёд по пустому результату.
- Без изменения схемы БД, без новых QG/стадий, без новых HTTP-endpoint'ов.
- Auth-reason виден в `/queue` для диагностики.
**Минусы / ограничения.**
- **Риск ложноположительного auth-fail** (см. `10-tech-risks.md` R-1): неверно
резолвленный путь к credentials заблокирует клейм **всех** проектов (общая очередь).
Митигируется: единый источник HOME (`AGENT_HOME`), тумблер `ORCH_PREFLIGHT_CHECK_AUTH`,
обязательная проверка на staging (8501) перед прод-деплоем.
- Проверка `expiresAt` — локальная; реально отозванный, но ещё не истёкший токен ловится
только постфактум-маркером (b).
- `expiresAt`-отсутствие трактуется как OK (компромисс против ложных срабатываний).
**Self-hosting.** Изменения только в слое preflight/launch; **не** требуют рестарта/падения
прод-контейнера `orchestrator` в рамках задачи. Выкатка — через staging-гейт (AC-17).
## Связи
- BRD `01-brd.md` (P1, P3), ТЗ `02-trz.md` (TR-1.x, TR-3.x; scope-коррекция),
Acceptance `03-acceptance-criteria.md` (AC-1…AC-6, AC-10…AC-17).
- Риски: `10-tech-risks.md`. Инфра: `07-infra-requirements.md`. БД: `08-data-requirements.md`.
- Код: `src/preflight.py`, `src/agents/launcher.py` (`_monitor_agent`, `_finalize_job`),
`src/config.py`, `src/usage.py` (`_extract_last_json_object`),
`src/error_classifier.py` (`classify_log_file`), `src/queue_worker.py` (без изменений).
- ORCH-1 (очередь/resilience), ORCH-7 (watchdog), ORCH-41 (resolver — **не трогаем effort**).
- **ORCH-50** — полноценный возврат `--effort` (вынесен из этой задачи).

View File

@@ -1,46 +0,0 @@
# 07 — Требования к инфраструктуре
**Work Item:** ORCH-044
**Основано на:** ADR-001, ТЗ `02-trz.md`
## Топология
**Без изменений.** Новых контейнеров, портов, сервисов, очередей не вводится. Прод
`orchestrator` (8500) и staging `orchestrator-staging` (8501) остаются как есть
(`docs/operations/INFRA.md`).
## Учётные данные claude (P1)
- Launcher спавнит claude с `HOME=/home/slin` (`src/agents/launcher.py`). Preflight ДОЛЖЕН
резолвить путь к credentials от **этого же** HOME, а не от окружения процесса оркестратора.
- Ожидаемое расположение файла OAuth-токена: **`/home/slin/.claude/.credentials.json`**
(структура: `claudeAiOauth.expiresAt` — epoch **ms**).
- Файл — секрет; в гит НЕ коммитится (правило агентов №8). На хосте монтируется в контейнер
как раньше; задача его расположение **не меняет**, только начинает читать.
- ⚠️ **Проверить на staging:** реальный путь файла внутри контейнера совпадает с
резолвленным preflight. Несовпадение ⇒ ложный auth-fail и блок очереди (R-1).
## Новые переменные окружения (env-карта)
Документировать в `docs/operations/INFRA.md` и docstring `src/config.py`:
| Env | Default | Назначение |
|-----|---------|------------|
| `ORCH_PREFLIGHT_CHECK_AUTH` | `true` | Включение token-free auth-проверки в preflight. Аварийный тумблер: `false` возвращает старое поведение (только bin + `--version`). |
| `ORCH_CLAUDE_CREDENTIALS_PATH` | `""` | Явный путь к `.credentials.json`. Пусто ⇒ `<AGENT_HOME>/.claude/.credentials.json`. |
| `ORCH_AUTH_EXPIRY_SKEW_SECONDS` | `0` | Запас на рассинхрон часов при сравнении `expiresAt`. |
`--effort` env (`ORCH_AGENT_EFFORT_*`) — **вне scope**; прод-хотфикс `ORCH_AGENT_EFFORT_*=""`
в `.env` **оставить как есть** (ORCH-50).
## Эксплуатационные процедуры
- **Аварийный откат auth-гейта без редеплоя кода:** выставить `ORCH_PREFLIGHT_CHECK_AUTH=false`
в `.env` и перезапустить воркер обычной процедурой выката (НЕ в рамках этой задачи).
- **Диагностика:** auth-причина видна в `GET /queue` (`preflight_reason`) и в warning-логе
`orchestrator.preflight`.
- **Re-login:** при детекте auth-маркера в логе launcher сбрасывает preflight-кеш, поэтому
после ручного `claude /login` следующий тик воркера (≤ `preflight_cache_ttl`) подхватит
валидную сессию автоматически.
## Self-hosting / деплой (AC-17)
- Изменения только в слое preflight/launch — **не** требуют рестарта/падения прод-контейнера
в рамках задачи.
- Выкатка self-доработки ORCH — **через staging-гейт (8501)** перед прод-деплоем
(CLAUDE.md, `docs/operations/INFRA.md`, ADR-0003).

View File

@@ -1,23 +0,0 @@
# 08 — Требования к схеме БД
**Work Item:** ORCH-044
**Основано на:** ADR-001, ТЗ `02-trz.md` §4
## Вердикт: изменений схемы НЕ требуется
Новых таблиц, колонок, индексов, миграций — **нет**.
P1 (auth-preflight) и P3 (пустой результат ⇒ провал) работают на **существующих** структурах:
- **`jobs`** — повторно используются существующие колонки для пути провала:
`status` (`queued`/`running`/`done`/`failed`), `error`, `attempts`, `max_attempts`,
`transient_attempts`, `available_at`, `run_id`. Пустой/невалидный результат идёт тем же
путём, что и обычный permanent/transient провал (`mark_job` / `mark_job_transient`).
- **`agent_runs`** — `exit_code` пишется без искажения (реальный код выхода процесса).
Решение done/fail принимается по отдельному in-memory флагу `result_ok` в `_monitor_agent`,
а не по колонке.
## Состояние данных
- Никаких бэкофиллов / data-migration.
- Auth-проверка читает **файл** `.credentials.json` (вне БД), результат кешируется in-memory
(`preflight._cache`), не персистится.

View File

@@ -1,20 +0,0 @@
# 10 — Технические риски
**Work Item:** ORCH-044
**Основано на:** ADR-001
| ID | Риск | Вероятн. | Влияние | Митигация |
|----|------|----------|---------|-----------|
| R-1 | **Ложноположительный auth-fail.** Неверно резолвленный путь к `.credentials.json` (иной HOME/маунт) ⇒ preflight всегда FAIL ⇒ **не клеймится ни одна job всех проектов** (общая очередь, self-hosting). | Средняя | **Высокое** | Единый источник HOME (`AgentLauncher.AGENT_HOME`, зеркально `_claude_bin()`); тумблер `ORCH_PREFLIGHT_CHECK_AUTH=false`; **обязательная проверка на staging** (реальный путь == резолвленный) перед прод-деплоем; информативный reason в `/queue` + warning-лог. |
| R-2 | **Fail-safe-провал на легитимном пустом выводе.** Агент легитимно завершился `exit 0` с непустым логом, но `_validate_result` ошибочно счёл результат невалидным ⇒ ложный `failed`/requeue (регрессия AC-13). | Низкая | Среднее | Контракт извлечения JSON — тот же, что у работающего usage-учёта (`_extract_last_json_object`); регресс-тест TC-15 (валидный лог ⇒ `done`); валидатор не трогает успешный путь, кроме булева флага. |
| R-3 | **`expiresAt` без сетевой валидации.** Реально отозванный, но ещё не истёкший по времени токен пройдёт проактивную проверку (a). | Средняя | Среднее | Защитная сетка постфактум (b): маркер `Not logged in` в логе ⇒ `error` + `preflight.reset_cache()` ⇒ следующий тик переоценивает auth; полная сетевая валидация — вне scope (BR-1). |
| R-4 | **`expiresAt` отсутствует/нечисловой** в файле (иная версия CLI / иной формат) ⇒ проверка трактует как OK и пропускает. | Низкая | Низкое | Осознанный компромисс против ложных срабатываний (см. ADR §P1.5); отсутствие токена/accessToken по-прежнему ⇒ FAIL; постфактум-маркер ловит реальный «не залогинен». |
| R-5 | **Часовой рассинхрон** контейнер vs токен ⇒ валидный токен сочтён истёкшим. | Низкая | Среднее | `ORCH_AUTH_EXPIRY_SKEW_SECONDS` (default 0) для запаса; контейнеры на одном хосте (mva154) — рассинхрон маловероятен. |
| R-6 | **Транзиентный auth (битый JSON в момент записи re-login).** Чтение файла во время атомарной перезаписи ⇒ временный FAIL. | Низкая | Низкое | Кеш TTL сглаживает; следующий тик перечитает; fail-safe в сторону «подождать» (job остаётся `queued`, не теряется). |
| R-7 | **Конфликт test-plan с коррекцией scope.** `04-test-plan.yaml` TC-09/TC-10/TC-11 проверяют `--effort` (variant B: «`--effort` не формируется»), но владелец **исключил** effort из ORCH-044 и оставил дефолты `agent_effort_*` = `high`. При дефолтной тест-конфигурации `_spawn` сформирует `--effort high` ⇒ TC-09 (ожидающий отсутствие флага) **упадёт**. | **Высокая** | Среднее | Developer/Tester: **адаптировать TC-09/10/11** под «effort не трогаем» (assert успешной сборки cmd без требования удаления флага, либо пометить как deferred→ORCH-50). Артефакт `04-test-plan.yaml` — чужой этап (правило №3), архитектор его НЕ редактирует, только фиксирует расхождение здесь. AC-7/AC-8/AC-9 не применяются (см. `03-acceptance-criteria.md` §P2). |
| R-8 | **Постфактум auth-сброс кеша зацикливает.** Повторные auth-провалы ⇒ повторные `reset_cache()`. | Низкая | Низкое | `reset_cache()` лишь форсирует один пересчёт; следующий `check()` снова закеширует на TTL; цикла «горячего» чтения нет; job не клеймится при FAIL. |
## Сводно
Доминирующий риск — **R-1** (блок очереди ложным auth-fail при неверном пути) и
организационный **R-7** (test-plan vs scope). Оба закрываются: R-1 — staging-проверкой +
тумблером, R-7 — правкой effort-тестов разработчиком/тестером согласно коррекции владельца.

View File

@@ -1,67 +0,0 @@
---
type: review
work_item_id: ORCH-044
verdict: APPROVED
version: 1
---
# Review ORCH-044
## Summary
PR закрывает две системные дыры слоя запуска агента (инцидент ORCH-17): **P1** — token-free
auth-гейт в preflight, **P3** — «пустой лог / нет result-JSON ⇒ провал». **P2 (`--effort`)
корректно исключён** из scope владельцем и вынесен в ORCH-50 — код effort (`_spawn`,
`resolve_agent_effort`, `agent_effort_*`) не тронут, что соответствует коррекции в 02-trz.md
и ADR-001.
Реализация полностью соответствует ТЗ и ADR-001. Документация обновлена в том же PR
(README.md, internals.md, INFRA.md, CHANGELOG.md, ADR заведён). Тесты зелёные
(`pytest tests/ -q` → 504 passed; новые `test_preflight_auth.py` + `test_empty_log_failure.py`
покрывают AC-1…AC-6, AC-10…AC-14). Verdict: **APPROVED**.
## Соответствие ТЗ / AC
- **P1 (TR-1.1…1.7):** `preflight._check_auth()` — чтение `<AGENT_HOME>/.credentials.json`,
валидация `claudeAiOauth.accessToken` + `expiresAt` (epoch ms, skew), never-raise fail-safe.
Путь резолвится от `AgentLauncher.AGENT_HOME` (новый `_agent_home()`, зеркально `_claude_bin()`),
а не от HOME процесса орка (TR-1.3 ✓). Встроено в кешируемый `_compute()` (TR-1.4 ✓).
Гейтинг клейма не требовал правок `_drain_once` (TR-1.5 ✓ — подтверждено
`test_worker_does_not_claim_when_auth_fails`). AC-1/2/3/4/5/6 покрыты тестами.
- **P3 (TR-3.1…3.5):** `_validate_result()` (лог непустой + trailing result-JSON по контракту
`usage._extract_last_json_object`), `success = exit 0 AND result_ok`. Побочные эффекты успеха
(`_post_usage_comments`, `_try_advance_stage`) выполняются только при `success`; при пустом
результате — Telegram-алерт + маршрутизация в провал через `_finalize_job(result_ok=False)`.
Реальный `exit_code` пишется в `agent_runs` без искажения (отдельный флаг — A4 из ADR).
AC-10/11/12/13/14 покрыты тестами (включая `test_never_running_after_empty_result`,
permanent/transient-классификацию).
- **P1b защитная сетка:** `_handle_auth_marker()` + `is_auth_failure_text()` сбрасывают
preflight-кеш при маркере разлогина в логе пути провала; не transient, breaker не крутится.
## Соответствие ADR
Реализация дословно следует ADR-001 (§P1 шаги 16, §P3 валидация + finalize, §Конфигурация:
`preflight_check_auth`/`claude_credentials_path`/`auth_expiry_skew_seconds`). Альтернативы A4/A5
отражены в коде (отдельный `result_ok` вместо подмены exit_code; общий TTL вместо отдельного
кеша). Verified: `usage._extract_last_json_object` и `preflight.reset_cache` существуют.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет (опционально: PydanticDeprecation warning в `config.py:4` — предсуществующий, вне scope ORCH-044).
## Документация
Обновлена корректно и в том же PR (правило агентов №2/№6, AC-15):
- `docs/architecture/README.md` — описание Preflight (auth) и Agent Launcher (валидация результата);
- `docs/architecture/internals.md` — §4 «Валидация результата», постфактум auth-детекция, таблица resilience, диаграмма `_finalize_job(result_ok)`;
- `docs/operations/INFRA.md` — env-карта (3 новые настройки) + раздел «Preflight auth-гейт» с риском R-1;
- `CHANGELOG.md` — запись `[Unreleased] / Added`;
- ADR `06-adr/ADR-001-preflight-auth-and-empty-result-failure.md` заведён; `10-tech-risks.md` присутствует.
## Self-hosting (AC-17)
Изменения только в слое preflight/launch — не требуют рестарта прод-контейнера в рамках задачи.
Выкатка через обязательный staging-гейт (8501) перед прод. Риск ложноположительного auth-fail
(R-1) митигирован тумблером `ORCH_PREFLIGHT_CHECK_AUTH` и проверкой на staging.

View File

@@ -1,84 +0,0 @@
---
type: test-report
work_item_id: ORCH-044
result: PASS
---
# Test Report — ORCH-044
Надёжность запуска агента: preflight auth (P1) + пустой лог = провал (P3).
**P2 (`--effort`) исключён из scope владельцем** (06.06) — вынесен в ORCH-50;
AC-7/AC-8/AC-9 и TC-09/TC-11 (effort) в этой задаче **не применяются (N/A)**.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Branch: feature/ORCH-044-preflight-auth-effort
- Дата: 2026-06-06T08:39Z
- Прод-инстанс (8500): не трогался; smoke — read-only GET.
## Результаты — Quality Gate тесты (04-test-plan.yaml)
| TC ID | Описание | Тест(ы) | Результат |
|-------|----------|---------|-----------|
| TC-01 | Нет `.credentials.json` ⇒ FAIL про auth | `test_missing_credentials_fails` | PASS |
| TC-02 | Протухший OAuth `expiresAt` ⇒ FAIL | `test_expired_token_fails` | PASS |
| TC-03 | Валидный логин ⇒ OK без регрессии | `test_valid_login_ok` | PASS |
| TC-04 | Битый JSON ⇒ FAIL без исключения | `test_broken_json_fails_without_raising` | PASS |
| TC-05 | Token-free: нет сетевого вызова | `test_auth_check_makes_no_network_call` | PASS |
| TC-06 | Кеширование auth в пределах TTL | `test_auth_result_cached_within_ttl` | PASS |
| TC-07 | Путь credentials от HOME агента (/home/slin) | `test_credentials_path_follows_agent_home` | PASS |
| TC-08 | Worker не клеймит job при auth-fail | `test_worker_does_not_claim_when_auth_fails` | PASS |
| TC-09 | (effort) cmd без `--effort` | `test_effort_flag.py` | N/A — scope исключён владельцем (ORCH-50) |
| TC-10 | `resolve_agent_effort` согласован | `test_resolve_agent_effort.py` (11 тестов) | PASS — effort не тронут, тесты зелёные |
| TC-11 | (effort) дефолтный путь даёт непустой JSON | `test_effort_flag.py` | N/A — scope исключён владельцем (ORCH-50) |
| TC-12 | Пустой лог + exit0 ⇒ failed + алерт | `test_empty_log_exit0_terminal_failed_alerts` | PASS |
| TC-13 | Лог без result-JSON ⇒ провал | `test_garbage_log_exit0_not_done` | PASS |
| TC-14 | Провал ⇒ нет advance/успешного коммента | `test_empty_result_suppresses_advance_and_comment` | PASS |
| TC-15 | Валидный JSON ⇒ done без регрессии | `test_valid_result_done`, `test_success_advances_and_comments` | PASS |
| TC-16 | Никогда не вечный `running` | `test_never_running_after_empty_result` | PASS |
| TC-17 | Классификация permanent/transient | `test_empty_result_defaults_permanent`, `..._with_transient_marker_goes_transient` | PASS |
| TC-18 | Регресс preflight (bin/version) | `test_resilience.py::TestPreflight` | PASS |
| TC-19 | Полный `pytest tests/` зелёный | вся сюита | PASS (504 passed) |
Дополнительно покрыто (вне нумерации плана): постфактум auth-маркер
(`test_is_auth_failure_text_*`, `TestAuthMarkerHandling`), тумблер
`ORCH_PREFLIGHT_CHECK_AUTH` (`test_auth_toggle_off_skips_check`), явный путь
credentials (`test_explicit_credentials_path_wins`).
## Сопоставление с критериями приёмки
- **AC-1…AC-6** (preflight auth): PASS — TC-01…TC-08.
- **AC-7/AC-8/AC-9** (effort): N/A — исключены владельцем (см. 02-trz.md, 03-acceptance-criteria.md).
- **AC-10…AC-14** (пустой результат ⇒ провал): PASS — TC-12…TC-16.
- **AC-15** (документация в том же PR): PASS — подтверждено review (APPROVED): README/internals/INFRA/CHANGELOG/ADR обновлены.
- **AC-16** (тесты зелёные): PASS — 504 passed.
- **AC-17** (self-hosting): PASS — изменения в слое preflight/launch; прод-контейнер не рестартовался; smoke 8500 read-only.
## Smoke test API (8500, read-only GET)
| Endpoint | Код | Замечание |
|----------|-----|-----------|
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
| GET /status | 200 | активна задача ORCH-044 (stage=testing) |
| GET /queue | 200 | counts ok (failed=0), `preflight_ok=true`, breaker=closed |
> curl в окружении отсутствует — smoke выполнен через `urllib` (эквивалентные GET).
## Вывод pytest
```
======================= 504 passed, 1 warning in 10.82s ========================
```
Модули плана (детально):
```
tests/test_preflight_auth.py ......... 18 passed
tests/test_resolve_agent_effort.py ... 11 passed
tests/test_empty_log_failure.py ...... 18 passed
tests/test_resilience.py ............. 31 passed
(итого по модулям плана: 78 passed)
```
Warning: `PydanticDeprecatedSince20` в `src/config.py:4` — предсуществующий,
вне scope ORCH-044 (зафиксировано в review как P2/опционально).
## Итог
**PASS** — все применимые тесты плана зелёные, существующая сюита не сломана,
smoke API исправен. TC-09/TC-11 (effort) корректно N/A: P2 исключён владельцем
и вынесен в ORCH-50. Задача готова к стадии **deploy-staging**.

View File

@@ -0,0 +1,49 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T08:41:49Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. All checks passed (10/10).
- Work item: ORCH-044
- Repo: orchestrator (self-hosting → staging gate is real, not a no-op)
- Container: `orchestrator-staging` (port 8501)
- Command (canonical, ran INSIDE the container so B6 reads the instance's own `.env.staging` process-env):
`python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
- Exit code: 0
## Results
```
[Block A] SMOKE
✓ PASS A1 GET /health → 200 status=ok
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience
✓ PASS A3 ORCH_STAGING=true (not prod)
[Block B] ACCESS
✓ PASS B4 Plane: sandbox project accessible
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent
[Block C] E2E (mode=stub)
✓ PASS C7 Create issue in Plane SANDBOX
✓ PASS C8 Trigger pipeline via /webhook/plane
✓ PASS C9a Branch appears in orchestrator-sandbox
✓ PASS C9b Analyst job enqueued in staging queue
[CLEANUP]
✓ PASS CLEANUP: deleted branch in orchestrator-sandbox
✓ PASS CLEANUP: deleted Plane issue
✓ PASS CLEANUP DB: deleted job + task rows
RESULT: 10/10 checks PASS
```
> Note: the host in this environment lacks the `docker` CLI, so the canonical
> `docker exec orchestrator-staging ...` was performed via the Docker Engine API
> over `/var/run/docker.sock` (Python stdlib, no host-env leakage). Semantics are
> identical to `docker exec`: the script ran inside `orchestrator-staging` with
> its own `.env.staging` process-env, keeping the B6 registry-isolation check valid.

View File

@@ -0,0 +1,120 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T21:03:18Z
work_item: ORCH-053
target: prod orchestrator (8500) — self-hosting
staging_gate: SUCCESS
db_migration: none
rebuild_required: true
restart_required: true
mode: artifact-validated; prod rebuild+restart handed off to Owner (self-hosting safeguard)
---
# Production Deploy Log — ORCH-053
`feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)`
## Verdict
`deploy_status: SUCCESS` — the deployable artifact is validated and ready, and the
automated deploy-stage responsibility is complete. ORCH-053 adds and changes **runtime
`src/` code** (new `src/reconciler.py` daemon thread wired into `main.lifespan`), so the
live prod rollout needs a container **rebuild + restart**. Per the self-hosting guardrail
that step is an **Owner action** (see Handoff) and was deliberately **NOT** performed by
this agent — the shared prod `orchestrator` (8500) serves all projects from one instance.
## Precondition: staging gate (`check_staging_status`)
`deploy` is reachable only because the staging gate (`deploy-staging`) passed:
- `15-staging-log.md``staging_status: SUCCESS`, **10/10 checks PASS** on the live
`orchestrator-staging` instance (8501), run inside the staging container
(ORCH-048 canon). The `GET /queue` smoke confirmed the ORCH-053 `reconcile` block is
exposed and the reconciler daemon runs in the staging stand without destabilising it.
This is the mandatory pre-prod safeguard for self-hosting (ADR-0003 staging gate).
## Change scope (why a prod rebuild+restart IS required)
ORCH-053 modifies code that lives **inside the prod image** and is executed by the
running app — unlike bind-mount-only changes (cf. ORCH-048):
| File | Kind | Reaches prod via |
|------|------|------------------|
| `src/reconciler.py` | **new** runtime daemon module (sweeper thread) | image rebuild |
| `src/main.py` | lifespan wiring: `reconciler.start()/stop()`, `/queue` reconcile block | image rebuild |
| `src/config.py` | reconciler settings (enabled / interval / grace / notify flags) | image rebuild |
| `src/db.py` | stuck-task query helpers (**no schema migration**) | image rebuild |
| `src/stage_engine.py` | reconciler-driven `advance_stage(finished_agent=None)` path | image rebuild |
| `src/plane_sync.py` | F-2 plane-side reconcile support | image rebuild |
| `src/webhooks/gitea.py` | F-3 `sha→branch` DB-fallback in `handle_ci_status` | image rebuild |
| `src/webhooks/plane.py` | F-2 handler reuse (`handle_status_start`/`handle_verdict`) | image rebuild |
| `tests/*`, `docs/*`, `.env.example`, `README.md` | tests + docs + env descriptor | n/a (not deployed) |
Because `src/` changed, the running prod process picks up ORCH-053 **only** after a
rebuild + restart of the shared prod `orchestrator` (8500).
## Database
**No schema migration.** ADR-0007 / ADR-001 invariant: the reconciler uses existing
tables (`tasks`, `jobs`, `agent_runs`) via new read helpers in `src/db.py`; `STAGE_TRANSITIONS`
and `QG_CHECKS` registries are unchanged. Restart-safe by construction (daemon re-derives
state from the DB on start).
## Deploy action
- **Prod container rebuild/restart:** required, **not performed** (guardrail: never
rebuild/restart the shared prod `orchestrator` within an ORCH task — it serves all
projects incl. enduro-trails from one instance with a shared DB/queue; an in-task
restart is a group risk for every project — CLAUDE.md §Self-hosting, INFRA.md §P-4).
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): **not
triggered** by this agent (not explicitly instructed; reserved for the Owner per
ORCH-36 / DEPLOY_HOOK.md).
- **Effective delivery:** merge of this branch to `main` lands the source of truth;
the prod cut-over (rebuild + restart) is the documented Owner step below.
## Safe-rollback posture
The reconciler ships with a runtime **kill-switch** independent of any redeploy:
`ORCH_RECONCILE_ENABLED=false` silences the entire sweeper, and
`ORCH_RECONCILE_PLANE_ENABLED=false` disables only the F-2 Plane-poll branch. If the
post-cut-over container is unhealthy, the deploy hook's 60s health loop **auto-rolls back**
to the previous image (snapshotted in `PREV_IMAGE_FILE`).
## Handoff — Owner prod cut-over (DEPLOY_HOOK.md, INFRA.md §Self-hosting)
Perform **only in a quiet window** and in this order:
1. **P-4 (BLOCKER)** — confirm `GET http://localhost:8500/status` shows **no active
tasks** before touching prod (shared instance with enduro-trails).
2. Land the source of truth: merge `feature/ORCH-053-sweeper-webhook-stuck-task``main`
(PR), then host `git pull` on `main` under uid 1000 (`/home/slin/repos/orchestrator`).
3. Prod cut-over via the deploy hook (conscious prod override — defaults are staging):
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
The hook snapshots the previous image, rebuilds+restarts, runs a 60s health loop on
`:8500/health`, and **auto-rolls back** if the new container is unhealthy.
4. Post-deploy smoke:
- `GET /health` → `200 {"status":"ok"}`.
- `GET /queue` → response carries the new `reconcile` block (interval, grace,
last-pass snapshot).
- Confirm a stuck task is unblocked by the sweeper (or that a synchronous task is
untouched — no spurious notifications), and `docker logs` shows the reconciler
thread started after the worker.
5. Optional staged rollout: set `ORCH_RECONCILE_NOTIFY_UNBLOCK=true` and watch the first
unblock; keep `ORCH_RECONCILE_ENABLED` as the instant kill-switch.
## Summary
| Item | State |
|------|-------|
| Staging gate (`check_staging_status`) | SUCCESS (10/10) |
| Change scope | runtime `src/` (new daemon) → rebuild+restart required |
| DB schema migration | none (existing tables; ADR-0007 invariant) |
| Kill-switch / rollback | `ORCH_RECONCILE_ENABLED` env + deploy-hook auto-rollback |
| In-task prod rebuild/restart | NOT performed (self-hosting safeguard, by design) |
| Prod cut-over | handed off to Owner (P-4 + deploy hook, prod override) |
| Deploy stage verdict | SUCCESS |

View File

@@ -0,0 +1,42 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T20:54:16Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
All checks passed — staging gate is GREEN.
## Run
- **Canonical execution:** inside container `orchestrator-staging` (ORCH-048, ADR-001).
The host environment has no `docker` CLI, so the `docker exec` was driven through the
Docker Engine API over the unix socket `/var/run/docker.sock` — functionally equivalent
to `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`. B6 registry-isolation therefore reads the
running staging instance's own process-env (`.env.staging`), avoiding the false-FAIL of a
host-side run.
- **Mode:** `stub` (early-artifact verification: branch + QG-0 comment; no LLM credits).
- **Container:** `orchestrator-staging` (095be2c4ca3f)
- **Exit code:** 0
## Result: 10/10 checks PASS
| Block | Check | Verdict |
|-------|-------|---------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 (counts/max_concurrency/resilience) | PASS |
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
| B ACCESS | B4 Plane sandbox project accessible | PASS |
| B ACCESS | B5 Gitea orchestrator-sandbox accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
| C E2E | C9a Branch appears in orchestrator-sandbox | PASS |
| C E2E | C9b Analyst job enqueued in staging queue | PASS |
Cleanup completed (sandbox branch + Plane issue + DB rows removed). The `GET /queue`
response exposed the `resilience` block; the ORCH-053 reconciler runs in this staging
instance without destabilising the stand.

View File

@@ -9,6 +9,10 @@
# TARGET_IMAGE - image name for retag (default: orchestrator-orchestrator-staging)
# COMPOSE_PROFILE - docker compose profile (default: staging)
# PREV_IMAGE_FILE - path to prev-image snapshot (default: $REPO/.deploy-prev-image-staging)
# SOURCE_IMAGE - build-once source image (default: unset; ORCH-36)
# When set, the prevalidated (staging) image is retagged onto
# TARGET_IMAGE instead of rebuilding — guarantees prod runs the
# exact artefact that passed staging (no `docker build`).
# LOG - log file path (default: /var/log/orchestrator/deploy-hook.log)
#
# Usage:
@@ -25,6 +29,9 @@ TARGET_PORT="${TARGET_PORT:-8501}"
TARGET_IMAGE="${TARGET_IMAGE:-orchestrator-orchestrator-staging}"
COMPOSE_PROFILE="${COMPOSE_PROFILE:-staging}"
PREV_IMAGE_FILE="${PREV_IMAGE_FILE:-$REPO/.deploy-prev-image-staging}"
# Build-once (ORCH-36): optional prevalidated source image to retag onto
# TARGET_IMAGE. Unset -> backward-compatible (no retag), exit-code contract intact.
SOURCE_IMAGE="${SOURCE_IMAGE:-}"
# ---- Log setup -------------------------------------------------------------
LOG_DIR=/var/log/orchestrator
@@ -139,10 +146,24 @@ else
log "No previous image captured (first deploy or service not running?)"
fi
# 2. Pull latest code
# 2. Pull latest code (keeps the host working tree current for future builds;
# the DEPLOYED artefact is the retagged SOURCE_IMAGE below when build-once).
log "git pull origin main"
git pull origin main >> "$LOG" 2>&1
# 2b. Build-once (ORCH-36): retag the prevalidated staging image onto TARGET_IMAGE
# instead of rebuilding, so prod runs the exact artefact that passed staging.
# Backward compatible: skipped when SOURCE_IMAGE is unset.
if [[ -n "$SOURCE_IMAGE" ]]; then
if docker image inspect "$SOURCE_IMAGE" >/dev/null 2>&1; then
log "BUILD-ONCE: retagging $SOURCE_IMAGE -> $TARGET_IMAGE (no rebuild)"
docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" >> "$LOG" 2>&1
else
log "BUILD-ONCE: SOURCE_IMAGE '$SOURCE_IMAGE' not found locally - aborting (exit 1)"
exit 1
fi
fi
# 3. Restart service
log "Starting $TARGET_SERVICE (profile=$COMPOSE_PROFILE)"
if [[ -n "$COMPOSE_PROFILE" ]]; then

View File

@@ -185,10 +185,6 @@ class AgentLauncher:
}
CLAUDE_BIN = "/opt/claude-code/bin/claude.exe"
# ORCH-044 (P1): HOME the claude subprocess actually runs under. preflight
# resolves the OAuth credentials path from this (NOT the orchestrator process
# HOME), so keep this single source of truth in sync with the spawn env below.
AGENT_HOME = "/home/slin"
# ORCH-7 (M-2): timeout is now configurable. AGENT_TIMEOUT stays as a
# backward-compatible alias for the default; the actual value (and per-agent
# overrides) live in settings and are resolved via _resolve_timeout().
@@ -218,7 +214,14 @@ class AgentLauncher:
Same spawn path as launch(), but threads job['id'] through so the monitor
can update the job's status (done / requeue / failed) and link jobs.run_id
to the agent_runs row. Returns the agent_run_id.
ORCH-036: the reserved-agent ``deploy-finalizer`` is a DETERMINISTIC
(no-LLM) job — intercept it BEFORE _spawn (which would raise
"Unknown agent", R-6) and run the deploy finalizer synchronously, driving
the jobs row status itself. Returns None (no agent_run row).
"""
if job.get("agent") == "deploy-finalizer":
return self._run_deploy_finalizer_job(job)
return self._spawn(
job["agent"],
job["repo"],
@@ -227,6 +230,27 @@ class AgentLauncher:
job_id=job["id"],
)
def _run_deploy_finalizer_job(self, job: dict):
"""ORCH-036 Phase C: run the deterministic deploy finalizer for a job.
Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row
done/failed here. Any error is contained (the finalizer never-raises, but
we guard anyway so a finalizer fault can't wedge the worker).
"""
from ..db import mark_job
from .. import stage_engine
try:
stage_engine.run_deploy_finalizer(job)
mark_job(job["id"], "done")
logger.info(f"deploy-finalizer job {job['id']} done")
except Exception as e:
logger.error(f"deploy-finalizer job {job['id']} failed: {e}")
try:
mark_job(job["id"], "failed", error=f"deploy-finalizer error: {e}")
except Exception:
pass
return None
def _spawn(self, agent: str, repo: str, task_content: str = None,
task_id: int = None, job_id: int = None) -> int:
"""Shared spawn implementation for launch() and launch_job().
@@ -327,7 +351,7 @@ class AgentLauncher:
stderr=subprocess.STDOUT,
env={
**os.environ,
"HOME": self.AGENT_HOME,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "claude-bot",
"GIT_AUTHOR_EMAIL": "claude-bot@mva154.local",
"GIT_COMMITTER_NAME": "claude-bot",
@@ -496,21 +520,6 @@ class AgentLauncher:
notify_agent_finished(run_id, agent, exit_code, task_id=_task_id, duration_s=_duration_s)
# ORCH-044 (P3): a clean exit_code==0 is NOT enough — claude can die fast
# (logged out, killed flag) leaving an empty / JSON-less log while still
# exiting 0. Validate the result; only (exit 0 AND result_ok) is success.
# The real exit_code is still recorded above without distortion; this flag
# drives the done/fail decision (ADR-001 §P3 / A4).
result_ok, result_reason = (True, "ok")
if exit_code == 0:
result_ok, result_reason = self._validate_result(output_path)
if not result_ok:
logger.warning(
f"Agent run_id={run_id} ({agent}) exited 0 but result invalid: "
f"{result_reason}"
)
success = (exit_code == 0 and result_ok)
# Feature 4: parse token usage / cost from the (json) run log and record
# it on the agent_runs row. Never fatal — a garbled/missing JSON records
# NULLs and logs a warning so a broken run can't crash the monitor.
@@ -529,7 +538,7 @@ class AgentLauncher:
try:
git_env = {
**os.environ,
"HOME": self.AGENT_HOME,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "claude-bot",
"GIT_AUTHOR_EMAIL": "claude-bot@mva154.local",
"GIT_COMMITTER_NAME": "claude-bot",
@@ -612,34 +621,11 @@ class AgentLauncher:
from ..notifications import send_telegram
send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
# ORCH-044 (P3): exit 0 with an empty/invalid result is a failure, not a
# success — alert (like other failures) and DO NOT post a success comment
# or advance the stage. The job-queue finalize below routes it to
# failed/retry. (AC-10/11/12.)
if exit_code == 0 and not success:
try:
conn = get_db()
task_row = conn.execute(
"SELECT work_item_id FROM tasks WHERE repo=? AND branch=?",
(repo, branch),
).fetchone()
conn.close()
_wid = task_row[0] if task_row else None
from ..notifications import send_telegram
send_telegram(
f"⚠️ {_wid or repo}: Agent {agent} exited 0 but produced "
f"an empty/invalid result ({result_reason}). "
f"Logs: /app/data/runs/{run_id}.log"
)
except Exception as e:
logger.warning(f"run_id={run_id}: empty-result alert failed: {e}")
# Feature 4 + ORCH-016: post the unified per-agent status comment under
# that agent's bot, threading the wall-clock duration we just measured
# straight through (ADR-001 §6: explicit param wins over DB fallback).
# The deployer finishing the task also posts the per-task usage summary.
# ORCH-044 (P3): only on real success (exit 0 AND valid result).
if success:
if exit_code == 0:
try:
self._post_usage_comments(
run_id, agent, repo, branch, _usage, duration_s=_duration_s
@@ -647,81 +633,14 @@ class AgentLauncher:
except Exception as e:
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
# Auto-advance stage if agent finished successfully and QG passes.
# ORCH-044 (P3): suppressed when the result was empty/invalid.
if success:
# Auto-advance stage if agent finished successfully and QG passes
if exit_code == 0:
self._try_advance_stage(run_id, agent, repo, branch)
# ORCH-1: drive the job-queue status for queue-launched jobs only.
# (Legacy direct launch() has job_id=None and is unaffected.)
# ORCH-044 (P3): result_ok lets _finalize_job treat an empty-result exit 0
# as a failure rather than 'done'.
if job_id is not None:
self._finalize_job(
job_id, agent, run_id, exit_code,
output_path=output_path, result_ok=result_ok,
)
@staticmethod
def _validate_result(output_path) -> tuple[bool, str]:
"""ORCH-044 (P3): is the run log a real result, or an empty/JSON-less death?
Returns (ok, reason). A run counts as a valid result only when the log
exists, is non-empty (not just whitespace), AND carries a parseable
trailing result-JSON object — the same contract usage accounting uses
(usage._extract_last_json_object). claude --output-format json always
emits exactly such an object on a real run, so its absence means the agent
died before producing anything.
Never raises: any error is treated as an invalid result (fail-safe toward
failing the job rather than silently passing — TR-3.5).
"""
try:
if not output_path:
return False, "no output path"
if not os.path.exists(output_path):
return False, "run log missing"
if os.path.getsize(output_path) == 0:
return False, "empty run log (0 bytes)"
with open(output_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
if not text.strip():
return False, "empty run log (whitespace only)"
from ..usage import _extract_last_json_object
if _extract_last_json_object(text) is None:
return False, "no result JSON in run log"
return True, "result ok"
except Exception as e: # pragma: no cover - defensive fail-safe
return False, f"result validation error: {e}"
def _handle_auth_marker(self, log_path) -> bool:
"""ORCH-044 (P1b): post-factum auth-failure detection (defensive net).
If an agent died because the session was logged out / expired between
preflight and spawn, reset the preflight cache so the NEXT worker tick
re-evaluates auth proactively (fast re-login pickup, or continued gating
if still broken). Auth failure is deliberately NOT treated as transient
and does NOT crank the circuit breaker — preflight is the right gate here.
Returns True if an auth marker was found. Never raises.
"""
try:
from .. import preflight
with open(log_path, "rb") as f:
try:
f.seek(-16384, 2)
except OSError:
f.seek(0)
text = f.read().decode("utf-8", errors="replace")
if preflight.is_auth_failure_text(text):
logger.warning(
f"Auth-failure marker in {log_path}; resetting preflight cache "
f"so the next tick re-checks auth"
)
preflight.reset_cache()
return True
except Exception:
pass
return False
self._finalize_job(job_id, agent, run_id, exit_code, output_path=output_path)
def _backoff_seconds(self, transient_attempts: int, retry_after: int = None) -> int:
"""Exponential backoff for transient failures, honouring Retry-After.
@@ -736,21 +655,17 @@ class AgentLauncher:
backoff = max(backoff, min(retry_after, cap))
return int(backoff)
def _finalize_job(self, job_id: int, agent: str, run_id: int, exit_code,
output_path=None, result_ok: bool = True):
def _finalize_job(self, job_id: int, agent: str, run_id: int, exit_code, output_path=None):
"""ORCH-1: update the jobs row after the agent process finished.
success = (exit_code == 0 AND result_ok) -> done (resets the breaker
streak via on_outcome). ORCH-044 (P3): result_ok==False means
exit 0 but the run log was empty / had no result-JSON, so it is
routed through the failure path below, NOT marked done.
otherwise -> classify the failure from the run log tail (token-free):
exit_code == 0 -> done (and resets the breaker streak via on_outcome).
exit_code != 0 -> classify the failure from the run log tail (token-free):
- TRANSIENT (429/overload/network): backoff-requeue with available_at in
the future + a SEPARATE transient_attempts budget
(settings.transient_max_attempts), honouring Retry-After. Reported to
the breaker so it opens after N consecutive transient failures.
- PERMANENT (code fault, incl. the empty-result case): ordinary
attempts < max_attempts requeue, otherwise 'failed' + Telegram.
- PERMANENT (code fault): ordinary attempts < max_attempts requeue,
otherwise 'failed' + Telegram.
"""
from ..db import get_job, mark_job
from ..error_classifier import classify_log_file
@@ -758,55 +673,34 @@ class AgentLauncher:
job = get_job(job_id)
if not job:
return
if exit_code == 0 and result_ok:
if exit_code == 0:
mark_job(job_id, "done", run_id=run_id)
logger.info(f"Job {job_id} ({agent}) done (run_id={run_id})")
self._record_outcome(transient=False, recovered=True)
return
log_path = output_path or f"/app/data/runs/{run_id}.log"
# ORCH-044 (P1b): if the failure was an auth death, invalidate the
# preflight cache so the next tick re-gates on auth proactively.
self._handle_auth_marker(log_path)
# ORCH-044 (P3): informative error for the empty/invalid-result case
# (exit 0 but no usable result). Defaults to permanent (it is not a
# 429/overload) unless the log carries a transient marker (TR-3.3).
empty_result = (exit_code == 0 and not result_ok)
override_err = (
f"empty run log / no result JSON (run_id={run_id})"
if empty_result else None
)
# Classify the failure from the agent log tail (no token cost).
kind, retry_after = "permanent", None
log_path = output_path or f"/app/data/runs/{run_id}.log"
try:
kind, retry_after = classify_log_file(log_path)
except Exception:
pass
if kind == "transient":
self._finalize_transient(job_id, agent, run_id, exit_code, job,
retry_after, error=override_err)
self._finalize_transient(job_id, agent, run_id, exit_code, job, retry_after)
else:
self._finalize_permanent(job_id, agent, run_id, exit_code, job,
error=override_err)
self._finalize_permanent(job_id, agent, run_id, exit_code, job)
except Exception as e:
logger.error(f"Job {job_id}: _finalize_job error: {e}")
def _finalize_transient(self, job_id, agent, run_id, exit_code, job, retry_after,
error: str | None = None):
"""Transient (429/overload/net) failure -> backoff requeue or fail when budget out.
ORCH-044 (P3): `error`, when provided, overrides the default transient
message (used for the empty-result case so the reason is informative).
"""
def _finalize_transient(self, job_id, agent, run_id, exit_code, job, retry_after):
"""Transient (429/overload/net) failure -> backoff requeue or fail when budget out."""
from ..db import mark_job, mark_job_transient
tattempts = job.get("transient_attempts", 0)
tmax = settings.transient_max_attempts
err = error or (f"transient (429/overload) agent {agent} exit={exit_code} "
f"(run_id={run_id}); retry_after={retry_after}")
err = (f"transient (429/overload) agent {agent} exit={exit_code} "
f"(run_id={run_id}); retry_after={retry_after}")
self._record_outcome(transient=True, recovered=False)
if tattempts < tmax:
backoff = self._backoff_seconds(tattempts + 1, retry_after)
@@ -823,17 +717,12 @@ class AgentLauncher:
self._notify_failed(job_id, agent, job, run_id,
f"transient (rate-limit) after {tattempts} attempts")
def _finalize_permanent(self, job_id, agent, run_id, exit_code, job,
error: str | None = None):
"""Permanent (code-fault) failure -> normal attempts<max requeue, then fail.
ORCH-044 (P3): `error`, when provided, overrides the default message
(used for the empty-result case, e.g. "empty run log / no result JSON").
"""
def _finalize_permanent(self, job_id, agent, run_id, exit_code, job):
"""Permanent (code-fault) failure -> normal attempts<max requeue, then fail."""
from ..db import mark_job
attempts = job.get("attempts", 0)
max_attempts = job.get("max_attempts", 2)
err = error or f"agent {agent} exit_code={exit_code} (run_id={run_id})"
err = f"agent {agent} exit_code={exit_code} (run_id={run_id})"
self._record_outcome(transient=False, recovered=False)
if attempts < max_attempts:
mark_job(job_id, "queued", run_id=run_id, error=err)

View File

@@ -64,25 +64,6 @@ class Settings(BaseSettings):
# breaker_threshold -> consecutive transient failures that OPEN the breaker.
# breaker_pause_seconds -> how long the breaker stays open before half-open.
preflight_cache_ttl: int = 45
# ORCH-044 (P1): token-free preflight auth gate. After `claude --version`
# succeeds, preflight also checks that claude is logged in by reading the
# local OAuth credentials file (no network / no prompt-ping — BR-1).
# preflight_check_auth -> master toggle (env ORCH_PREFLIGHT_CHECK_AUTH).
# Emergency off-switch if the check ever
# false-positives and wedges the shared queue.
# claude_credentials_path -> explicit path to .credentials.json
# (env ORCH_CLAUDE_CREDENTIALS_PATH). Empty ->
# <AGENT_HOME>/.claude/.credentials.json, where
# AGENT_HOME is the HOME the launcher really
# spawns claude under (/home/slin), NOT the
# orchestrator process env.
# auth_expiry_skew_seconds -> clock-drift slack when comparing
# claudeAiOauth.expiresAt (env
# ORCH_AUTH_EXPIRY_SKEW_SECONDS); a token within
# this many seconds of now is treated as expired.
preflight_check_auth: bool = True
claude_credentials_path: str = ""
auth_expiry_skew_seconds: int = 0
backoff_base_seconds: int = 10
backoff_max_seconds: int = 600
transient_max_attempts: int = 5
@@ -149,10 +130,84 @@ class Settings(BaseSettings):
ci_poll_max_attempts: int = 12
ci_poll_interval_s: int = 10
# ORCH-043: merge-gate (auto-rebase + re-test + merge-lock) on the
# deploy-staging -> deploy edge. A deterministic sub-gate (no LLM) that
# catches the up-to-date branch up to the CURRENT origin/main, re-tests it,
# and serialises merges so two green branches can't break main.
# merge_gate_enabled -> global kill-switch; False -> no-op pass for the
# whole gate (staged rollout, env ORCH_MERGE_GATE_ENABLED).
# merge_gate_repos -> CSV of repos where the gate is REAL; empty means
# only the self-hosting repo (orchestrator). Other
# repos -> conditional no-op (mirrors ORCH-35 staging).
# merge_retest_timeout_s -> wall-clock budget for the post-rebase re-test.
# merge_retest_target -> pytest target for the re-test (portability across repos).
# merge_lock_timeout_s -> max lease age; an older lease is reclaimed (crash backstop).
# merge_defer_delay_s -> delay before re-running the gate when the lock is busy.
# merge_defer_max_attempts -> defer retries before escalation (avoids livelock).
merge_gate_enabled: bool = True
merge_gate_repos: str = ""
merge_retest_timeout_s: int = 600
merge_retest_target: str = "tests/"
merge_lock_timeout_s: int = 300
merge_defer_delay_s: int = 60
merge_defer_max_attempts: int = 5
# ORCH-036: executable self-deploy (deploy stage drives the host hook).
# The `deploy` stage for the self-hosting repo is turned into a REAL prod
# restart via a detached host process, gated by a manual approve. Three-phase
# design (ADR-001): A=approve-request, B=initiate (human Approved), C=finalizer
# maps the hook exit-code -> deploy_status. Non-self repos are unaffected.
#
# self_deploy_enabled -> global kill-switch; False -> no Phase A/B/C
# interception (the legacy synchronous deployer
# path runs for everyone, env ORCH_SELF_DEPLOY_ENABLED).
# self_deploy_repos -> CSV of repos where executable self-deploy is
# REAL; empty -> only the self-hosting repo
# (orchestrator). Mirrors merge_gate_repos.
# deploy_require_manual_approve -> require a human Approved before the prod
# restart (BR-5). Default true; NOT toggled in
# ORCH-36 (AC-12). false -> Phase A initiates
# immediately (structural branch, off by default).
# deploy_finalize_delay_s -> delay before the first finalize poll; must be
# > the hook health-loop (~60s) so the verdict
# usually exists on the first poll.
# deploy_finalize_max_attempts -> bounded finalize-defer budget (anti-livelock).
# ssh / hook target (detached prod restart; real values live on the host):
# deploy_ssh_user / deploy_ssh_host -> ssh target for the host hook (INFRA P-2).
# deploy_hook_script -> path to the hook ON THE HOST (relative to repo).
# deploy_host_repo_path -> orchestrator clone path on the host.
# prod overrides passed to the hook for build-once (retag staging image -> prod):
# deploy_prod_source_image -> image validated on staging (retagged, no rebuild).
# deploy_prod_target_service / _port / _image / _compose_profile -> prod profile.
# deploy_prod_prev_image_file -> prod prev-image snapshot (separate from staging).
self_deploy_enabled: bool = True
self_deploy_repos: str = ""
deploy_require_manual_approve: bool = True
deploy_finalize_delay_s: int = 90
deploy_finalize_max_attempts: int = 10
deploy_ssh_user: str = "slin"
deploy_ssh_host: str = ""
deploy_hook_script: str = "scripts/orchestrator-deploy-hook.sh"
deploy_host_repo_path: str = "/home/slin/repos/orchestrator"
deploy_prod_source_image: str = "orchestrator-orchestrator-staging"
deploy_prod_target_service: str = "orchestrator"
deploy_prod_target_port: int = 8500
deploy_prod_target_image: str = "orchestrator-orchestrator"
deploy_prod_compose_profile: str = ""
deploy_prod_prev_image_file: str = ".deploy-prev-image-prod"
# Telegram notifications
telegram_bot_token: str = ""
telegram_chat_id: str = ""
# ORCH-042: режим live-трекера задачи.
# edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было).
# bump -> при обновлении старое сообщение удаляется и карточка отправляется
# заново вниз чата (deleteMessage + sendMessage + repoint message_id),
# тихо (disable_notification). Одна карточка на задачу в обоих режимах.
# Неизвестное/пустое значение трактуется как edit (см. notifications).
tracker_mode: str = "edit"
class Config:
env_prefix = "ORCH_"
env_file = ".env"

View File

@@ -324,19 +324,34 @@ def enqueue_job(
task_content: str | None = None,
task_id: int | None = None,
max_attempts: int = 2,
available_at_delay_s: int | None = None,
) -> int:
"""Enqueue a new job (status='queued'). Returns the new job id.
This is what webhook handlers call instead of launching an agent in-process:
it is a fast DB INSERT that returns immediately. The background worker
(queue_worker) picks the job up later.
ORCH-043 (merge-gate defer): when ``available_at_delay_s`` is given the job's
``available_at`` is set to ``now + delay`` so claim_next_job won't pick it up
until the delay elapses (re-uses the existing ORCH-1 backoff gate). Used to
re-queue the staging-deployer after a "merge-lock busy" defer without burning a
worker slot in a blocking wait.
"""
conn = get_db()
cursor = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts) "
"VALUES (?, ?, ?, ?, ?)",
(agent, repo, task_id, task_content, max_attempts),
)
if available_at_delay_s is not None:
cursor = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts, available_at) "
"VALUES (?, ?, ?, ?, ?, datetime('now', ?))",
(agent, repo, task_id, task_content, max_attempts,
f"+{int(available_at_delay_s)} seconds"),
)
else:
cursor = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts) "
"VALUES (?, ?, ?, ?, ?)",
(agent, repo, task_id, task_content, max_attempts),
)
job_id = cursor.lastrowid
conn.commit()
conn.close()

340
src/merge_gate.py Normal file
View File

@@ -0,0 +1,340 @@
"""Merge-gate core (ORCH-043): catch a branch up to the CURRENT origin/main,
re-test it, and serialise merges with a file lease.
Background
----------
The pipeline validates a branch against the ``main`` it was BRANCHED from, not the
``main`` at the moment of merge. Between "branch validated" and "branch merged" a
parallel task may have advanced ``main`` -> a *semantic* merge conflict: git merges
with no textual conflict, yet the combined ``main`` is broken. For the self-hosting
``orchestrator`` repo that means a red ``main`` of the tool serving every project.
This module provides the deterministic (no-LLM) primitives the quality-gate
``check_branch_mergeable`` (src/qg/checks.py) composes on the
``deploy-staging -> deploy`` edge, BEFORE the deployer merges the PR:
* ``branch_is_behind_main`` -> is the branch missing the latest origin/main?
* ``auto_rebase_onto_main`` -> rebase onto origin/main + push --force-with-lease
(ONLY the task branch; NEVER main).
* ``retest_branch`` -> run the project test-suite in the caught-up worktree.
* file lease (``acquire_merge_lease`` / ``release_merge_lease``) -> serialise the
"catch-up + re-test + merge" of ONE repo, held from the gate to the actual merge.
Invariants (self-hosting safety, ТЗ §10):
* NEVER push or force-push ``main`` — the only force op is ``--force-with-lease``
on the task branch.
* All git ops run in the per-branch worktree (ensure_worktree), never the shared clone.
* Every public function honours a strict **never-raise** contract: any git/OS error
-> ``(False, "<reason>")`` (or a safe bool), never a propagated exception.
"""
import json
import logging
import os
import subprocess
import time
from .config import settings
from .git_worktree import ensure_worktree, get_worktree_path
logger = logging.getLogger("orchestrator.merge_gate")
# git sub-command timeouts (seconds). Generous but bounded so a hung git never
# wedges the monitor-thread that runs the gate.
_FETCH_TIMEOUT = 60
_REBASE_TIMEOUT = 120
_PUSH_TIMEOUT = 60
_SHORT_TIMEOUT = 30
# ---------------------------------------------------------------------------
# behind / ancestor detection
# ---------------------------------------------------------------------------
def branch_is_behind_main(repo: str, branch: str) -> bool:
"""Return True iff ``branch`` does NOT already contain the latest origin/main.
A branch is "behind" when ``origin/main`` is **not** an ancestor of the branch
HEAD (``git merge-base --is-ancestor origin/main HEAD`` returns non-zero). All
work happens in the per-branch worktree (ORCH-2 / S-4 isolation).
Never-raise (AC-9 / TC-03): any git/OS failure or an ambiguous result is treated
as "cannot prove the branch is up-to-date" -> return True (force a rebase attempt
rather than merge blindly). It returns a bool, never raises.
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("branch_is_behind_main: worktree error for %s/%s: %s", repo, branch, e)
return True
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_FETCH_TIMEOUT,
)
r = subprocess.run(
["git", "-C", wt, "merge-base", "--is-ancestor", "origin/main", "HEAD"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("branch_is_behind_main: git error for %s/%s: %s", repo, branch, e)
return True
if r.returncode == 0:
# origin/main IS an ancestor of HEAD -> branch already up-to-date.
return False
if r.returncode == 1:
# origin/main is NOT an ancestor -> branch is behind.
return True
# Any other code (e.g. bad ref) -> ambiguous; do not merge blindly.
logger.warning(
"branch_is_behind_main: ambiguous merge-base rc=%s for %s/%s (treating as behind)",
r.returncode, repo, branch,
)
return True
def _conflicted_files(wt: str) -> str:
"""Best-effort list of unmerged (conflicting) files in the worktree."""
try:
r = subprocess.run(
["git", "-C", wt, "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, timeout=_SHORT_TIMEOUT,
)
files = r.stdout.strip().replace("\n", ", ")
return files or "unknown"
except (subprocess.SubprocessError, OSError):
return "unknown"
# ---------------------------------------------------------------------------
# auto-rebase onto origin/main
# ---------------------------------------------------------------------------
def auto_rebase_onto_main(repo: str, branch: str) -> tuple[bool, str]:
"""Catch ``branch`` up to ``origin/main`` via rebase, then push it.
Steps (all in the per-branch worktree):
1. ``git fetch origin main``.
2. ``git rebase origin/main``:
- textual conflict (non-zero) -> ``git rebase --abort`` (leave worktree
clean) -> ``(False, "rebase conflict: <files>")`` (AC-3).
3. clean rebase -> ``git push --force-with-lease origin <branch>`` — ONLY the
task branch, NEVER ``main`` (AC-7) -> ``(True, "rebased onto origin/main")``.
Never-raise (AC-9): any git/OS error -> ``(False, "<reason>")``.
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
return False, f"rebase setup error: {e}"
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_FETCH_TIMEOUT,
)
r = subprocess.run(
["git", "-C", wt, "rebase", "origin/main"],
capture_output=True, text=True, timeout=_REBASE_TIMEOUT,
)
if r.returncode != 0:
files = _conflicted_files(wt)
subprocess.run(
["git", "-C", wt, "rebase", "--abort"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
logger.warning("auto_rebase: conflict on %s/%s: %s", repo, branch, files)
return False, f"rebase conflict: {files}"
# Clean rebase -> push ONLY the task branch with a lease (never main).
p = subprocess.run(
["git", "-C", wt, "push", "--force-with-lease", "origin", branch],
capture_output=True, text=True, timeout=_PUSH_TIMEOUT,
)
if p.returncode != 0:
detail = (p.stderr or p.stdout or "").strip()[:200]
logger.warning("auto_rebase: push failed on %s/%s: %s", repo, branch, detail)
return False, f"push --force-with-lease failed: {detail}"
logger.info("auto_rebase: %s/%s rebased onto origin/main and pushed", repo, branch)
return True, "rebased onto origin/main"
except subprocess.TimeoutExpired:
# Leave no half-finished rebase behind.
try:
subprocess.run(
["git", "-C", wt, "rebase", "--abort"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError):
pass
return False, "rebase timeout"
except (subprocess.SubprocessError, OSError) as e:
return False, f"rebase error: {e}"
# ---------------------------------------------------------------------------
# re-test in the caught-up worktree
# ---------------------------------------------------------------------------
def retest_branch(repo: str, branch: str) -> tuple[bool, str]:
"""Run the project test-suite in the (already caught-up) branch worktree.
Command: ``python -m pytest <merge_retest_target>`` (default ``tests/``),
matching the orchestrator CI / check_tests_local pattern. Bounded by
``settings.merge_retest_timeout_s``.
Returns:
* ``(True, "re-test green")`` — pytest rc == 0
* ``(False, "re-test timeout after <T>s")`` — exceeded the timeout (AC-6)
* ``(False, "re-test failed: ...<tail>")`` — non-zero rc, with output tail
Never-raise (AC-9): any setup/OS error -> ``(False, "<reason>")``.
"""
wt = get_worktree_path(repo, branch)
if not os.path.isdir(wt):
# Caller usually rebased first (worktree exists); ensure as a fallback.
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
return False, f"re-test setup error: {e}"
target = settings.merge_retest_target or "tests/"
timeout = settings.merge_retest_timeout_s
try:
r = subprocess.run(
["python", "-m", "pytest", target, "-q"],
cwd=wt, capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
logger.warning("retest_branch: timeout (%ss) on %s/%s", timeout, repo, branch)
return False, f"re-test timeout after {timeout}s"
except (subprocess.SubprocessError, OSError) as e:
return False, f"re-test error: {e}"
if r.returncode == 0:
return True, "re-test green"
tail = ((r.stdout or "") + (r.stderr or ""))[-500:]
logger.warning("retest_branch: red on %s/%s", repo, branch)
return False, f"re-test failed: ...{tail}"
# ---------------------------------------------------------------------------
# merge-lease (serialise catch-up + re-test + merge per repo)
# ---------------------------------------------------------------------------
def _lease_path(repo: str) -> str:
"""Filesystem path of the per-repo merge lease (no schema change, ТЗ §4)."""
return os.path.join(settings.repos_dir, f".merge-lease-{repo}.json")
def _read_lease(path: str) -> dict | None:
"""Read+parse the lease file; None if missing or corrupt (never-raise)."""
try:
with open(path, "r", encoding="utf-8") as f:
return json.loads(f.read())
except FileNotFoundError:
return None
except (OSError, ValueError) as e:
logger.warning("merge-lease read error at %s: %s", path, e)
return None
def _write_lease(path: str, holder: dict) -> None:
"""Atomically (O_CREAT|O_EXCL) write the lease; raises FileExistsError if held."""
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
os.write(fd, json.dumps(holder).encode("utf-8"))
finally:
os.close(fd)
def acquire_merge_lease(
repo: str, branch: str, work_item_id: str | None = None, task_id: int | None = None
) -> tuple[bool, str]:
"""Try to acquire the per-repo merge lease. **Non-blocking** (anti-deadlock).
Holder identity is the task ``branch`` (stable, one branch per task). Outcomes:
* no lease file -> acquire, write metadata -> ``(True, "lease acquired")``
* lease held by self -> idempotent re-acquire (restart/retry) -> ``(True, "lease already held")``
* lease held by other, age < merge_lock_timeout_s -> ``(False, "merge-lock busy")``
* lease held by other, age >= merge_lock_timeout_s -> stale -> reclaim with a
``logger.warning`` (the holder process died without releasing) -> ``(True, ...)``
Never-raise: any unexpected error -> ``(False, "merge-lock busy")`` so the caller
DEFERS and retries rather than burning a developer retry on an infra hiccup.
"""
path = _lease_path(repo)
holder = {
"branch": branch,
"work_item_id": work_item_id,
"task_id": task_id,
"acquired_at": time.time(),
"pid": os.getpid(),
}
try:
try:
_write_lease(path, holder)
logger.info("merge-lease acquired for %s by %s", repo, branch)
return True, "lease acquired"
except FileExistsError:
pass
existing = _read_lease(path)
if existing is None:
# Corrupt/empty lease file — reclaim it.
_force_write_lease(path, holder)
logger.warning("merge-lease for %s was corrupt; reclaimed by %s", repo, branch)
return True, "lease reclaimed (corrupt)"
if existing.get("branch") == branch:
return True, "lease already held"
age = time.time() - float(existing.get("acquired_at") or 0)
if age >= settings.merge_lock_timeout_s:
_force_write_lease(path, holder)
logger.warning(
"merge-lease for %s was stale (age %.0fs >= %ss, holder=%s); reclaimed by %s",
repo, age, settings.merge_lock_timeout_s, existing.get("branch"), branch,
)
return True, "lease reclaimed (stale)"
logger.info(
"merge-lease for %s busy (held by %s, age %.0fs); %s defers",
repo, existing.get("branch"), age, branch,
)
return False, "merge-lock busy"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("acquire_merge_lease unexpected error for %s/%s: %s", repo, branch, e)
return False, "merge-lock busy"
def _force_write_lease(path: str, holder: dict) -> None:
"""Overwrite the lease (used for stale/corrupt reclaim). Best-effort."""
try:
with open(path, "w", encoding="utf-8") as f:
f.write(json.dumps(holder))
except OSError as e:
logger.warning("merge-lease force-write error at %s: %s", path, e)
def release_merge_lease(repo: str, branch: str | None = None) -> None:
"""Release the per-repo merge lease. **Idempotent** and **holder-aware**.
If ``branch`` is given, the lease is removed ONLY when the current holder's
branch matches (so a delayed release from an already-merged task can never
delete a lease a DIFFERENT task acquired afterwards). With ``branch=None`` the
release is unconditional (best-effort backstop). Never raises.
"""
path = _lease_path(repo)
try:
if branch is not None:
existing = _read_lease(path)
if existing is not None and existing.get("branch") != branch:
logger.info(
"merge-lease release skipped for %s: holder=%s != %s",
repo, existing.get("branch"), branch,
)
return
os.remove(path)
logger.info("merge-lease released for %s (%s)", repo, branch or "force")
except FileNotFoundError:
return
except OSError as e:
logger.warning("merge-lease release error for %s: %s", repo, e)

View File

@@ -68,6 +68,62 @@ def send_telegram(text: str, disable_notification: bool = False):
return None
# Telegram error descriptions that mean a deleteMessage target is already gone /
# can't be deleted (>48h, already deleted, invalid id). Treated as "no longer our
# problem" -> the caller proceeds to send a fresh card. NOT a transient failure.
_DELETE_GONE_MARKERS = (
"message to delete not found",
"message can't be deleted",
"message_id_invalid",
)
def delete_telegram(message_id: int) -> bool:
"""Delete a Telegram message. Never raises.
Returns True if the message is gone after the call (deleted now, OR Telegram
says it's already not there / can't be deleted -> treat as "no longer our
problem", caller proceeds to send a fresh card). Returns False only on a
transient failure (network / timeout / 5xx / unknown error) where the old
message may still be alive.
"""
s = _get_settings()
if not s.telegram_bot_token or not s.telegram_chat_id:
# No creds -> nothing was deleted; mirror the other helpers' no-op path.
return False
try:
url = f"https://api.telegram.org/bot{s.telegram_bot_token}/deleteMessage"
resp = httpx.post(
url,
json={
"chat_id": s.telegram_chat_id,
"message_id": message_id,
},
timeout=5,
)
data = resp.json()
if data.get("ok"):
return True
# ok:false -> classify. "Already gone / can't delete" is an expected,
# non-transient outcome (>48h, already deleted) -> the old message is no
# longer there, caller should still send a fresh card.
desc = str(data.get("description") or "").lower()
if any(m in desc for m in _DELETE_GONE_MARKERS):
logger.debug(
f"delete_telegram(mid={message_id}): already gone ({desc!r})"
)
return True
# Unknown 400 / 5xx -> transient; the old message may still be alive.
logger.warning(
f"delete_telegram(mid={message_id}): delete failed ({desc!r})"
)
return False
except Exception as e:
# Network / timeout -> transient; old message may still be alive.
logger.warning(f"delete_telegram(mid={message_id}): transient error: {e}")
return False
# edit_telegram outcome codes -> let update_task_tracker decide what to do:
# "ok" edit applied -> nothing else to do
# "not_modified" Telegram says text is identical (400 "message is not
@@ -166,19 +222,23 @@ def _get_work_item_id(task_id: int) -> str:
# the agent whose agent_runs rows describe that stage's work. "Ревью БРД" is NOT
# an agent stage — it is the human approve gate rendered between Analysis and
# Architecture from the task's brd_review_* timestamps.
# ORCH-042 (BR-11): display-labels are Russian. Stage KEYS (analysis, …) and
# agent names (analyst, …) are NOT touched — they are wired to
# _STAGE_ACTIVE_AGENT, last_done and the DB. Only the 2nd tuple element changed.
_TRACKER_STAGES = [
("analysis", "Analysis", "analyst"),
("architecture", "Architecture", "architect"),
("development", "Development", "developer"),
("review", "Review", "reviewer"),
("testing", "Testing", "tester"),
("deploy", "Deploy", "deployer"),
("analysis", "Анализ", "analyst"), # Анализ
("architecture", "Архитектура", "architect"), # Архитектура
("development", "Разработка", "developer"), # Разработка
("review", "Код ревью", "reviewer"), # Код ревью
("testing", "Тестирование", "tester"), # Тестирование
("deploy", "Внедрение", "deployer"), # Внедрение
]
# Map a pipeline stage -> the agent that is RUNNING while the task sits in it.
# (development is entered after architecture finishes, etc.) Used to render the
# "🔄 <Stage> … идёт" line for the currently-active stage.
_BRD_LABEL = "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" # "Ревью БРД"
# ORCH-042 (BR-9): "Подтверждение BRD" (was "Ревью БРД").
_BRD_LABEL = "Подтверждение BRD"
_STAGE_ACTIVE_AGENT = {
"analysis": "analyst",
@@ -232,7 +292,8 @@ def render_task_tracker(task_id: int) -> str:
the BRD-review timestamps, then renders:
- one '✅ <Stage> <dur> · <in>↓/<out>↑ · <cost> · <model>' line per finished
stage (latest run per stage),
- the '⏸️ Ревью БРД <dur> · твоё время[ ⏳]' line between Analysis/Architecture,
- the '✅/⏸️ Подтверждение BRD <dur> · твоё время[ ⏳]' line between
Analysis/Architecture (✅ once the approve-gate passed, ⏸️+⏳ while waiting),
- a '🔄 <Stage> … идёт' line for the active (in-progress) stage,
- the '💰 <in>↓ / <out>↑ · <cost>' totals,
- on done: '⏱️ Всего .. · агенты .. · твоё ..' and a '🔗 PR / 📦' line.
@@ -365,9 +426,11 @@ def render_task_tracker(task_id: int) -> str:
if stage_key == "analysis" and brd_started:
brd_label = f"{_BRD_LABEL:<13}"
if review_seconds is not None:
# ORCH-042 (BR-10): approve-gate passed -> \u2705 (was \u23f8\ufe0f). The
# still-waiting branch below keeps \u23f8\ufe0f + \u23f3 unchanged.
dur = _fmt_minutes(review_seconds)
lines.append(
f"\u23f8\ufe0f {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
f"\u2705 {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
)
else:
# Still waiting on the human (ended not stamped yet).
@@ -406,7 +469,7 @@ def render_task_tracker(task_id: int) -> str:
def _done_link(task_id: int, work_item_id) -> str | None:
"""Build the final '🔗 PR #n · 📦 deployed' line. Never raises -> None."""
"""Build the final '🔗 PR #n · 📦 Внедрено' line. Never raises -> None."""
try:
from .config import settings
from .db import get_db
@@ -436,7 +499,7 @@ def _done_link(task_id: int, work_item_id) -> str | None:
parts = []
if pr_part:
parts.append(pr_part)
parts.append("\U0001f4e6 deployed")
parts.append("\U0001f4e6 Внедрено") # ORCH-042 (BR-12): was "deployed"
return " \u00b7 ".join(parts)
except Exception:
return None
@@ -445,19 +508,49 @@ def _done_link(task_id: int, work_item_id) -> str | None:
def update_task_tracker(task_id: int):
"""Render + push the live tracker for a task. Never raises.
First call (no stored tracker_message_id): sendMessage (silent) and store the
returned message_id. Subsequent calls: editMessageText the stored message.
A NEW message is sent ONLY when the original is truly gone (deleted / too old
/ invalid id). On "not modified" (text unchanged) or transient failures
(network / timeout / 5xx / unknown 400) we do NOT send a new message — that
is exactly what produced duplicate trackers and orphaned (lagging) messages.
Two modes, selected by Settings.tracker_mode (env ORCH_TRACKER_MODE),
resolved case-insensitively here; anything other than "bump" -> "edit"
(ORCH-042). Both keep the "one card per task" invariant.
edit (DEFAULT):
First call (no stored tracker_message_id): sendMessage (silent) and store
the returned message_id. Subsequent calls: editMessageText the stored
message. A NEW message is sent ONLY when the original is truly gone
(deleted / too old / invalid id). On "not modified" (text unchanged) or
transient failures (network / timeout / 5xx / unknown 400) we do NOT send
a new message — that is exactly what produced duplicate trackers and
orphaned (lagging) messages.
bump (ORCH-042):
The card is re-created at the BOTTOM of the chat on every update:
best-effort delete_telegram(old_id) (its result NEVER blocks the send),
then sendMessage (silent), then re-point tracker_message_id to the new id
— but ONLY on a successful send (new_mid is not None), so a transient send
failure never wipes the pointer to None. At most ONE new message is sent
per call -> no duplicates within a call.
The tracker is always sent with disable_notification so it never pings —
only the dedicated alert helpers ping.
"""
try:
from .db import get_tracker_message_id, set_tracker_message_id
text = render_task_tracker(task_id)
mode = (_get_settings().tracker_mode or "edit").strip().lower()
mid = get_tracker_message_id(task_id)
if mode == "bump":
# bump: one card, always at the bottom (delete + send + repoint).
if mid is not None:
# best-effort; result does NOT gate the send (BR-6).
delete_telegram(mid)
new_mid = send_telegram(text, disable_notification=True)
if new_mid is not None:
set_tracker_message_id(task_id, new_mid)
# send returned None (no creds / transient) -> leave mid untouched;
# no duplicate within this call, redraws on the next transition.
return
# mode == "edit" (DEFAULT): existing behaviour, unchanged.
if mid is not None:
result = edit_telegram(mid, text)
if result in (EDIT_OK, EDIT_NOT_MODIFIED):

View File

@@ -5,25 +5,14 @@ are reachable WITHOUT spending any tokens. We only do local/cheap checks:
1. os.path.exists(CLAUDE_BIN) -- instant
2. `claude --version` (timeout 5s) -- spawns CLI, does NOT call the API
3. auth check (ORCH-044, P1) -- read the local OAuth credentials file
The result is cached for `preflight_cache_ttl` seconds so we do not re-run
`claude --version` (or re-read the credentials file) on every worker tick.
`claude --version` on every worker tick.
🚫 We deliberately do NOT do a prompt ping (ping->pong) — that would burn the
rate limit and add latency. Preflight is local-only.
ORCH-044 (P1): `claude --version` answers successfully even when claude is NOT
logged in (the version is local information), so version-only preflight was blind
to auth. We add a token-free auth gate: read <AGENT_HOME>/.claude/.credentials.json
and validate the OAuth token (presence + expiry). Combined with a post-factum
`Not logged in` marker detection (is_auth_failure_text), this stops a logged-out
instance from claiming jobs and silently dying with an empty run log. No network
call is ever made here.
"""
import os
import re
import json
import time
import logging
import subprocess
@@ -34,15 +23,6 @@ logger = logging.getLogger("orchestrator.preflight")
_VERSION_TIMEOUT = 5
# ORCH-044 (P1b): post-factum auth-failure markers. If an agent started under a
# session that died/expired between preflight and spawn, these substrings in the
# run log identify the auth failure so the launcher can invalidate the preflight
# cache (forcing the next tick to re-evaluate auth proactively).
_AUTH_FAIL_RE = re.compile(
r"not logged in|please run\s*/login|invalid api key|unauthorized|\b401\b",
re.IGNORECASE,
)
class _PreflightCache:
def __init__(self):
@@ -94,120 +74,11 @@ def _run_version(bin_path: str) -> tuple[bool, str]:
return False, f"--version error: {e}"
def _agent_home() -> str:
"""Resolve the HOME the launcher actually spawns claude under (ORCH-044, TR-1.3).
The auth credentials live under the *agent's* HOME (/home/slin), which the
launcher injects into the claude subprocess env — NOT the orchestrator
process HOME. We mirror _claude_bin()'s "follow the genuinely executed path"
approach by reading AgentLauncher.AGENT_HOME. Falls back to the known default
if the launcher cannot be imported (e.g. isolated unit test).
"""
try:
from .agents.launcher import AgentLauncher
home = getattr(AgentLauncher, "AGENT_HOME", None)
if home:
return home
except Exception:
pass
return "/home/slin"
def _credentials_path() -> str:
"""Path to claude's OAuth credentials file (ORCH-044, P1).
settings.claude_credentials_path wins when set; otherwise
<AGENT_HOME>/.claude/.credentials.json.
"""
explicit = (getattr(settings, "claude_credentials_path", "") or "").strip()
if explicit:
return explicit
return os.path.join(_agent_home(), ".claude", ".credentials.json")
def _iso(epoch_ms) -> str:
"""Best-effort epoch-ms -> ISO-8601 UTC string (for human-readable reasons)."""
try:
from datetime import datetime, timezone
return datetime.fromtimestamp(int(epoch_ms) / 1000, tz=timezone.utc).isoformat()
except Exception:
return str(epoch_ms)
def is_auth_failure_text(text: str) -> bool:
"""ORCH-044 (P1b): True if `text` contains a claude auth-failure marker.
Used post-factum on a run log so the launcher can tell an auth death apart
from a generic failure and reset the preflight cache. Never raises.
"""
if not text:
return False
try:
return bool(_AUTH_FAIL_RE.search(text))
except Exception:
return False
def _check_auth() -> tuple[bool, str]:
"""ORCH-044 (P1a): token-free local auth gate. Never raises.
Steps (ADR-001 §P1):
1. credentials file missing / unreadable / invalid JSON -> not ok.
2. no claudeAiOauth block / accessToken -> not ok.
3. claudeAiOauth.expiresAt (epoch ms) <= now + skew -> expired -> not ok.
4. accessToken present but expiresAt absent/unparsable -> OK (cannot prove
expiry; we do not manufacture false positives that would wedge the shared
queue — see ADR Risks R-1).
Fail-safe: any unexpected error returns (False, ...) so a logged-out / broken
state never claims a job (BR-2 / TR-3.5). This reads only a local file — no
network call, no token spend (BR-1 / AC-5).
"""
try:
path = _credentials_path()
if not os.path.exists(path):
return False, f"claude not logged in: credentials missing ({path})"
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError) as e:
return False, f"claude not logged in: credentials unreadable ({e})"
oauth = data.get("claudeAiOauth") if isinstance(data, dict) else None
if not isinstance(oauth, dict) or not oauth.get("accessToken"):
return False, "claude not logged in: no oauth token"
expires = oauth.get("expiresAt")
if expires is None:
return True, "auth ok (no expiry recorded)"
try:
expires_ms = int(expires)
except (TypeError, ValueError):
return True, "auth ok (unparsable expiry)"
skew_ms = int(getattr(settings, "auth_expiry_skew_seconds", 0) or 0) * 1000
now_ms = int(time.time() * 1000)
if expires_ms <= now_ms + skew_ms:
return False, f"OAuth token expired at {_iso(expires_ms)}"
return True, "auth ok"
except Exception as e: # pragma: no cover - defensive fail-safe
return False, f"auth check error: {e}"
def _compute() -> tuple[bool, str]:
bin_path = _claude_bin()
if not os.path.exists(bin_path):
return False, f"CLAUDE_BIN not found: {bin_path}"
ok, reason = _run_version(bin_path)
if not ok:
return ok, reason
# ORCH-044 (P1): version is local info and answers even when logged out, so
# gate on a token-free auth check too. Toggleable for emergencies.
if getattr(settings, "preflight_check_auth", True):
auth_ok, auth_reason = _check_auth()
if not auth_ok:
return False, auth_reason
return True, reason
return _run_version(bin_path)
def check(force: bool = False) -> tuple[bool, str]:

View File

@@ -621,6 +621,87 @@ def check_staging_status(repo: str, work_item_id: str, branch: str | None = None
return False, "Staging log not found (15-staging-log.md)"
def _merge_gate_applies(repo: str) -> bool:
"""Whether the merge-gate is REAL for this repo (ORCH-043, conditional rollout).
Mirrors the ORCH-35 conditional staging-gate. ``merge_gate_repos`` is a CSV of
repos where the gate is enforced; when empty the gate is real ONLY for the
self-hosting repo (``orchestrator``). Other repos -> conditional no-op.
"""
raw = (settings.merge_gate_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
return is_self_hosting_repo(repo)
def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-043 merge-gate: validate the branch against the CURRENT origin/main
immediately before the deployer merges its PR (deploy-staging -> deploy edge).
Deterministic, no LLM. Algorithm (ADR-001 §4):
1. Conditionality: merge_gate_enabled=False -> (True, "merge-gate disabled");
repo where the gate is not real -> (True, "merge-gate N/A for <repo>").
2. Acquire the per-repo merge lease (NON-blocking). Busy -> (False, "merge-lock
busy") — a SIGNAL for the engine to DEFER (not a code fault, no rollback).
3. Double-check "behind origin/main" UNDER the lease (main may have moved while
we waited). Not behind -> (True, "branch up-to-date with main"); lease HELD.
4. Behind -> auto_rebase_onto_main:
- conflict -> release lease -> (False, "rebase conflict: ...")
- clean -> retest_branch:
green -> (True, "rebased onto main, re-test green"); lease HELD
red/timeout -> release lease -> (False, "re-test ... after rebase")
5. On SUCCESS the lease is HELD until the actual merge (released on PR-merged
webhook / deploy->done / rollback). On any FAILURE the lease is released.
Never-raise (AC-9): any internal error -> (False, "<reason>") with the lease
released; an exception never escapes into advance_stage.
"""
# Imported lazily so qg.checks stays importable without the merge_gate deps in
# minimal/test contexts and to avoid an import cycle surprise.
from .. import merge_gate
try:
if not settings.merge_gate_enabled:
return True, "merge-gate disabled"
if not _merge_gate_applies(repo):
return True, f"merge-gate N/A for {repo}"
acquired, reason = merge_gate.acquire_merge_lease(repo, branch, work_item_id)
if not acquired:
# "merge-lock busy" -> caller defers; lease NOT held by us, nothing to release.
return False, reason
try:
# Double-check under the lease: another task may have just merged.
if not merge_gate.branch_is_behind_main(repo, branch):
logger.info("check_branch_mergeable: %s up-to-date with main", branch)
return True, "branch up-to-date with main"
ok, rb_reason = merge_gate.auto_rebase_onto_main(repo, branch)
if not ok:
merge_gate.release_merge_lease(repo, branch)
return False, rb_reason # "rebase conflict: ..."
ok_t, t_reason = merge_gate.retest_branch(repo, branch)
if ok_t:
logger.info("check_branch_mergeable: %s rebased + re-test green", branch)
return True, "rebased onto main, re-test green"
merge_gate.release_merge_lease(repo, branch)
if "timeout" in t_reason:
return False, t_reason # "re-test timeout after <T>s" (AC-6)
tail = t_reason.removeprefix("re-test failed: ")
return False, f"re-test failed after rebase: {tail}"
except Exception as e: # noqa: BLE001 - never-raise; always release on error
merge_gate.release_merge_lease(repo, branch)
logger.error("check_branch_mergeable inner error for %s/%s: %s", repo, branch, e)
return False, f"merge-gate error: {e}"
except Exception as e: # noqa: BLE001 - outer never-raise guard
logger.error("check_branch_mergeable error for %s/%s: %s", repo, branch, e)
return False, f"merge-gate error: {e}"
# Registry for dynamic lookup by name
QG_CHECKS = {
"check_analysis_approved": check_analysis_approved,
@@ -633,4 +714,5 @@ QG_CHECKS = {
"check_tests_local": check_tests_local,
"check_deploy_status": check_deploy_status,
"check_staging_status": check_staging_status,
"check_branch_mergeable": check_branch_mergeable,
}

338
src/self_deploy.py Normal file
View File

@@ -0,0 +1,338 @@
"""Executable self-deploy primitives (ORCH-036).
The ``deploy`` stage for the self-hosting ``orchestrator`` repo is a REAL prod
restart, not a paper LLM verdict. Because the prod container (8500) runs the
worker/agent itself, the restart must be performed by an EXTERNAL host process
that survives the container dying (BR-2). The orchestration is split into three
deterministic phases (ADR-001), wired in ``stage_engine``:
* Phase A — request approve on the ``deploy-staging -> deploy`` edge.
* Phase B — a human Plane ``Approved`` initiates the detached host deploy.
* Phase C — a deterministic finalizer maps the hook exit-code -> deploy_status.
This module is a **leaf**: it imports only config / git_worktree (and lazily
``qg.checks.is_self_hosting_repo``), never ``stage_engine`` / ``launcher`` — the
orchestration that needs those lives in ``stage_engine``. Every public helper
honours a **never-raise** contract so a deploy-state hiccup can never crash the
stage engine.
Restart-safe state lives in sentinel files under
``<repos_dir>/.deploy-state-<repo>/<work_item_id>/`` (mirrors the merge-lease
pattern, ТЗ §4 — no DB migration), on the shared mount visible to BOTH the
container (reads markers) and the host (writes ``result``):
* ``approve-requested`` — Phase A done;
* ``initiated`` — Phase B started (idempotency-guard);
* ``result`` — the hook exit-code, written by the host WRAPPER
(``echo $? > result``), NOT by the hook itself.
"""
import logging
import os
import shlex
import shutil
import subprocess
from .config import settings
logger = logging.getLogger("orchestrator.self_deploy")
# Sentinel marker filenames (see module docstring).
APPROVE_REQUESTED = "approve-requested"
INITIATED = "initiated"
RESULT = "result"
# ssh launch is detached (returns immediately); keep a bounded timeout so a hung
# ssh handshake never wedges the caller.
_SSH_TIMEOUT = 30
_GIT_TIMEOUT = 60
# ---------------------------------------------------------------------------
# Conditionality
# ---------------------------------------------------------------------------
def self_deploy_applies(repo: str) -> bool:
"""Whether executable self-deploy (Phase A/B/C) is REAL for this repo.
Mirrors the ORCH-35 / ORCH-43 conditional rollout:
* ``self_deploy_enabled=False`` -> always False (global kill-switch); the
legacy synchronous deployer path runs for everyone.
* ``self_deploy_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
Never raises.
"""
try:
if not settings.self_deploy_enabled:
return False
raw = (settings.self_deploy_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (avoids importing qg at module load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("self_deploy_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# exit-code -> deploy_status mapping (pure, unit-tested: TC-01/02/03)
# ---------------------------------------------------------------------------
def map_exit_code_to_status(exit_code) -> str:
"""Map a deploy-hook exit-code to a machine verdict (deterministic, pure).
Contract (AC-1 / AC-3, hook exit-code contract 0/1/2):
* ``0`` -> ``"SUCCESS"`` (health-ok proven by the hook).
* ``1`` (rolled back), ``2`` (rollback also failed), anything else, or a
non-int/None -> ``"FAILED"`` (fail-closed; never advances on doubt).
"""
try:
code = int(exit_code)
except (TypeError, ValueError):
return "FAILED"
return "SUCCESS" if code == 0 else "FAILED"
def build_deploy_log(work_item_id: str, exit_code, status: str) -> str:
"""Render a 14-deploy-log.md body whose ``deploy_status:`` frontmatter is the
verdict ``check_deploy_status`` / ``_parse_deploy_status`` reads (contract
unchanged, AC-10). The body is informational only — only the frontmatter is
machine-read.
"""
return (
"---\n"
f"deploy_status: {status}\n"
f"work_item: {work_item_id}\n"
f"hook_exit_code: {exit_code}\n"
"deployed_by: deploy-finalizer\n"
"---\n\n"
"# Deploy log — ORCH-036 executable self-deploy\n\n"
f"Прод-деплой завершён хост-хуком с exit-code `{exit_code}` -> "
f"`deploy_status: {status}`.\n\n"
"Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.\n"
)
# ---------------------------------------------------------------------------
# Sentinel state (restart-safe, no DB migration — ТЗ §4)
# ---------------------------------------------------------------------------
def _state_dir(base: str, repo: str, work_item_id: str | None) -> str:
return os.path.join(base, f".deploy-state-{repo}", (work_item_id or "_"))
def container_state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen FROM THE CONTAINER (settings.repos_dir mount)."""
return _state_dir(settings.repos_dir, repo, work_item_id)
def host_state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen FROM THE HOST (settings.host_repos_dir).
Same physical directory as ``container_state_dir`` via the shared mount; the
host path is what we embed in the ssh command so the host wrapper writes the
``result`` sentinel where the container can read it.
"""
return _state_dir(settings.host_repos_dir, repo, work_item_id)
def marker_path(repo: str, work_item_id: str | None, name: str) -> str:
return os.path.join(container_state_dir(repo, work_item_id), name)
def has_marker(repo: str, work_item_id: str | None, name: str) -> bool:
"""True iff the named sentinel exists. Never raises."""
try:
return os.path.isfile(marker_path(repo, work_item_id, name))
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("has_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool:
"""Create/overwrite a sentinel (best-effort). Returns True on success."""
try:
d = container_state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, name), "w", encoding="utf-8") as f:
f.write(str(content))
return True
except OSError as e:
logger.warning("write_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def clear_state(repo: str, work_item_id: str | None) -> bool:
"""Remove ALL deploy-state sentinels for this work item (best-effort).
Sentinels are keyed by ``work_item_id`` (stable for the whole task lifetime),
so a FAILED prod-deploy leaves ``approve-requested`` / ``initiated`` / ``result``
behind. Without cleanup, after the БАГ-8 rollback (deploy -> development) and a
fix, the task reaching ``deploy`` again would hit Phase B's idempotency-guard:
the STALE ``initiated`` makes it a no-op, the detached hook never re-launches and
the task wedges on ``deploy`` forever (re-deploy-after-rollback contract broken;
AC-4/AC-10). A stale ``result`` would likewise be mis-read by the new finalizer.
Clearing the whole state dir restores a clean slate for the next pass. Idempotent
(a missing dir is success). Never raises.
"""
d = container_state_dir(repo, work_item_id)
try:
shutil.rmtree(d)
logger.info("clear_state: removed deploy-state dir %s", d)
return True
except FileNotFoundError:
return True
except OSError as e: # noqa: BLE001 - never-raise contract
logger.warning("clear_state error for %s/%s: %s", repo, work_item_id, e)
return False
def read_result(repo: str, work_item_id: str | None) -> tuple[bool, int | None]:
"""Read the ``result`` sentinel (hook exit-code written by the host wrapper).
Returns ``(present, exit_code)``:
* ``(False, None)`` -> not written yet (finalizer should DEFER);
* ``(True, <int>)`` -> verdict ready;
* ``(True, 1)`` -> present but corrupt/unparseable -> treated as a
failure code (fail-closed) so we never advance on garbage.
Never raises.
"""
p = marker_path(repo, work_item_id, RESULT)
try:
with open(p, "r", encoding="utf-8") as f:
raw = f.read().strip()
except FileNotFoundError:
return False, None
except OSError as e:
logger.warning("read_result error for %s/%s: %s", repo, work_item_id, e)
return False, None
if raw == "":
return False, None
try:
return True, int(raw)
except ValueError:
logger.warning("read_result: corrupt result %r for %s/%s", raw, repo, work_item_id)
return True, 1
# ---------------------------------------------------------------------------
# Detached host deploy: ssh + setsid (Phase B)
# ---------------------------------------------------------------------------
def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> list[str]:
"""Build the ssh argv that launches the DETACHED prod deploy on the host.
The remote command runs the hook via ``setsid`` with stdin/stdout detached and
backgrounded (``&``) so the process SURVIVES the prod container restart (BR-2),
then the WRAPPER (not the hook) writes the exit-code to the ``result`` sentinel:
setsid bash -c 'cd <repo> && <prod env...> bash <hook> --deploy; \
echo $? > <result>' >> <hook.log> 2>&1 </dev/null &
Build-once (BR-6): ``SOURCE_IMAGE=<staging-image>`` makes the hook retag the
staging-validated image to the prod tag instead of rebuilding (no ``docker
build``). The exit-code contract of the hook is untouched.
"""
host_dir = host_state_dir(repo, work_item_id)
result_sentinel = os.path.join(host_dir, RESULT)
hook_log = os.path.join(host_dir, "hook.log")
env_assignments = (
f"SOURCE_IMAGE={shlex.quote(settings.deploy_prod_source_image)} "
f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} "
f"TARGET_PORT={int(settings.deploy_prod_target_port)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_target_image)} "
f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} "
f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}"
)
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "
f"bash {shlex.quote(settings.deploy_hook_script)} --deploy; "
f"echo $? > {shlex.quote(result_sentinel)}"
)
remote = (
f"setsid bash -c {shlex.quote(inner)} "
f">> {shlex.quote(hook_log)} 2>&1 </dev/null &"
)
user = (settings.deploy_ssh_user or "").strip()
host = (settings.deploy_ssh_host or "").strip()
target = f"{user}@{host}" if user else host
return ["ssh", "-o", "StrictHostKeyChecking=no", target, remote]
def initiate_deploy(repo: str, work_item_id: str | None, branch: str) -> tuple[bool, str]:
"""Launch the detached prod deploy on the host (Phase B). Never raises.
The ssh call returns immediately (the remote process is detached via setsid +
``&``). Returns ``(True, msg)`` when ssh dispatched the detached process, or
``(False, reason)`` so the caller can alert and let the human re-approve.
"""
# Ensure the shared state dir exists so the host wrapper can write `result`.
try:
os.makedirs(container_state_dir(repo, work_item_id), exist_ok=True)
except OSError as e:
logger.warning("initiate_deploy: state dir error for %s/%s: %s", repo, work_item_id, e)
cmd = build_deploy_command(repo, work_item_id, branch)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_SSH_TIMEOUT)
except subprocess.TimeoutExpired:
return False, "ssh launch timeout"
except (subprocess.SubprocessError, OSError) as e:
return False, f"ssh launch error: {e}"
if r.returncode != 0:
detail = ((r.stderr or "") + (r.stdout or "")).strip()[:200]
return False, f"ssh launch failed (rc={r.returncode}): {detail}"
logger.info("initiate_deploy: detached prod deploy dispatched for %s/%s", repo, work_item_id)
return True, "deploy initiated (detached host process)"
# ---------------------------------------------------------------------------
# Deploy log write + best-effort merge (Phase C)
# ---------------------------------------------------------------------------
def write_deploy_log(repo: str, work_item_id: str, branch: str, exit_code, status: str) -> bool:
"""Write 14-deploy-log.md into the task worktree (so check_deploy_status reads
it) and best-effort commit+push it. Returns True iff the file was written.
Never raises.
"""
from .git_worktree import get_worktree_path
rel = f"docs/work-items/{work_item_id}/14-deploy-log.md"
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.error("write_deploy_log: worktree error for %s/%s: %s", repo, branch, e)
return False
path = os.path.join(wt, rel)
content = build_deploy_log(work_item_id, exit_code, status)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
logger.error("write_deploy_log: write error at %s: %s", path, e)
return False
# Best-effort commit + push (the gate also falls back to origin/main).
git_env = {
**os.environ,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "deploy-finalizer",
"GIT_AUTHOR_EMAIL": "deploy-finalizer@mva154.local",
"GIT_COMMITTER_NAME": "deploy-finalizer",
"GIT_COMMITTER_EMAIL": "deploy-finalizer@mva154.local",
}
try:
subprocess.run(["git", "-C", wt, "add", rel],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
commit = subprocess.run(
["git", "-C", wt, "commit", "-m",
f"deploy(ORCH-036): finalize {status} for {work_item_id}"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env,
)
if commit.returncode == 0:
subprocess.run(["git", "-C", wt, "push", "origin", branch],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("write_deploy_log: git commit/push best-effort failed: %s", e)
return True

View File

@@ -27,6 +27,7 @@ Agent-selection bug fix (ORCH-4):
import logging
import os
import time
from dataclasses import dataclass, field
from .db import get_db, update_task_stage, enqueue_job
@@ -34,6 +35,8 @@ from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage
from .git_worktree import get_worktree_path
from .review_parse import extract_review_findings, extract_test_failures
from .qg.checks import QG_CHECKS
from . import merge_gate
from . import self_deploy
from .notifications import (
notify_stage_change,
notify_qg_failure,
@@ -189,6 +192,23 @@ def advance_stage(
result.note = "terminal"
return result
# --- ORCH-036 Phase B: human Approved on `deploy` -> initiate deploy --
# A human flipping the Plane status to Approved on the `deploy` stage
# (finished_agent is None) is the prod-deploy trigger for the self-hosting
# repo. Initiate the DETACHED host deploy + enqueue the finalizer and
# return WITHOUT running check_deploy_status (the verdict does not exist
# yet — running the gate now would read a stale/absent log and falsely
# roll back, R-2). The finalizer (Phase C, finished_agent="deployer")
# records the verdict later; that path is NOT intercepted here.
if (
current_stage == "deploy"
and finished_agent is None
and settings.deploy_require_manual_approve
and self_deploy.self_deploy_applies(repo)
):
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
return result
# --- Quality gate ----------------------------------------------------
if qg_name and qg_name in QG_CHECKS:
# Human-approval gate: split by path.
@@ -239,6 +259,34 @@ def advance_stage(
result.note = f"qg '{qg_name}' not in registry"
return result
# --- ORCH-043 merge-gate sub-gate (deploy-staging -> deploy edge) -----
# AFTER check_staging_status passed and BEFORE we advance to `deploy` /
# launch the deployer that merges the PR. Not a STAGE_TRANSITIONS entry —
# it is an edge sub-gate triggered by the same "staging-deployer finished"
# event. If it intervenes (defer on busy-lock, or rollback on conflict /
# red re-test) it owns the outcome and we return without advancing.
if current_stage == "deploy-staging":
if _handle_merge_gate(
task_id, current_stage, repo, work_item_id, branch, agent, result
):
return result
# --- ORCH-036 Phase A: request approve before the prod deploy ---------
# On the deploy-staging -> deploy edge, AFTER a green check_staging_status
# and the merge-gate, the self-hosting repo does NOT auto-launch a prod
# deployer. Instead advance the STAGE to `deploy`, put the issue into an
# approval-pending state and wait for a human Approved (Phase B). The
# merge lease stays HELD across the wait (released on done / rollback).
if (
current_stage == "deploy-staging"
and settings.deploy_require_manual_approve
and self_deploy.self_deploy_applies(repo)
):
_handle_self_deploy_phase_a(
task_id, current_stage, repo, work_item_id, branch, result
)
return result
# --- Advance ---------------------------------------------------------
update_task_stage(task_id, next_stage)
# Telegram live tracker: the analysis->architecture advance is the human
@@ -274,6 +322,15 @@ def advance_stage(
except Exception as e:
logger.error(f"Task {task_id}: failed to set Plane Done: {e}")
# ORCH-043: the merge has landed (deploy->done). Release the merge lease as
# a backstop in case the PR-merged webhook was lost (holder-aware no-op if a
# different task already owns it). Never raises.
if next_stage == "done":
try:
merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}")
# --- Launch the next agent (ORCH-4 fix: current_stage, not next) -----
next_agent = get_agent_for_stage(current_stage)
if next_agent:
@@ -565,6 +622,22 @@ def _handle_qg_failure_rollbacks(
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
# ORCH-036: clear the deploy-state sentinels (approve-requested / initiated /
# result) so the NEXT prod-deploy pass (after the developer fixes and the task
# returns to `deploy`) is not wedged by Phase B's idempotency-guard reading a
# STALE `initiated`, nor the finalizer mis-reading a STALE `result`. Markers are
# keyed by work_item_id (stable across the rollback), so without this they
# survive into the retry and break re-deploy-after-rollback (AC-4/AC-10).
try:
self_deploy.clear_state(repo, work_item_id)
except Exception as e: # noqa: BLE001 - defensive (clear_state never-raises anyway)
logger.warning(f"Task {task_id}: deploy-state clear on deploy-fail failed: {e}")
# ORCH-043: deploy failed -> no merge will complete; release the lease so the
# next task isn't blocked until the lease ages out (holder-aware no-op).
try:
merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on deploy-fail failed: {e}")
set_issue_blocked(work_item_id)
notify_qg_failure(task_id, "deploy", "check_deploy_status", reason)
plane_add_comment(
@@ -582,3 +655,357 @@ def _handle_qg_failure_rollbacks(
f"Task {task_id}: deployer verdict FAILED, rolled back deploy -> "
f"development ({reason})"
)
# ---------------------------------------------------------------------------
# ORCH-043: merge-gate sub-gate on the deploy-staging -> deploy edge
# ---------------------------------------------------------------------------
def _merge_defer_count(task_id: int) -> int:
"""How many times this task has already been deferred by the merge-gate.
Counted from the persisted jobs queue (restart-safe) by the defer marker in
task_content, so a service restart never resets the defer budget.
"""
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND task_content LIKE '%merge-gate defer%'",
(task_id,),
).fetchone()[0]
conn.close()
return n
def _handle_merge_gate(
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
) -> bool:
"""Run check_branch_mergeable on the deploy-staging -> deploy edge.
Returns True if the gate INTERVENED (the caller must return without advancing):
* "merge-lock busy" -> DEFER (re-queue the staging-deployer with a
delay; the task stays on deploy-staging). Code
is fine, so NO rollback and no developer retry.
* conflict / red re-test -> ROLLBACK to development (+ developer retry,
capped by MAX_DEVELOPER_RETRIES).
Returns False when the gate PASSED (branch up-to-date, or rebased + re-test green)
so advance_stage proceeds to `deploy` and launches the deployer that merges. On a
PASS the merge lease is HELD until the actual merge (released on PR-merged webhook
/ deploy->done / rollback).
"""
passed, reason = _run_qg("check_branch_mergeable", repo, work_item_id, branch)
if passed:
logger.info(f"Task {task_id}: merge-gate passed ({reason})")
return False
result.qg_name = "check_branch_mergeable"
result.qg_passed = False
result.qg_reason = reason
if reason == "merge-lock busy":
_handle_merge_gate_defer(
task_id, current_stage, repo, work_item_id, branch, result
)
return True
_handle_merge_gate_rollback(
task_id, current_stage, repo, work_item_id, branch, reason, result
)
return True
def _handle_merge_gate_defer(
task_id, current_stage, repo, work_item_id, branch, result: AdvanceResult
):
"""merge-lock busy -> DEFER: re-queue the staging-deployer after a delay.
Non-blocking: the worker slot is freed (anti-deadlock at max_concurrency=1) so
the lease HOLDER can finish merging. The task remains on deploy-staging; a later
staging-deployer run re-evaluates the gate. Bounded by merge_defer_max_attempts.
"""
defers = _merge_defer_count(task_id)
if defers < settings.merge_defer_max_attempts:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy-staging\nNote: merge-gate defer "
f"(attempt {defers + 1}/{settings.merge_defer_max_attempts}) — "
f"merge-lock busy, retrying after {settings.merge_defer_delay_s}s."
)
new_job = enqueue_job(
"deployer", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.merge_defer_delay_s,
)
result.enqueued_agent = "deployer"
result.enqueued_job_id = new_job
result.note = "merge-gate-deferred"
logger.info(
f"Task {task_id}: merge-lock busy, deferred deployer "
f"(job_id={new_job}, attempt {defers + 1}/{settings.merge_defer_max_attempts})"
)
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: merge-gate defer limit "
f"({settings.merge_defer_max_attempts}) reached (merge-lock busy). "
f"Manual intervention needed."
)
result.alerted = True
result.note = "merge-gate-defer-exhausted"
logger.error(
f"Task {task_id}: merge-gate defer attempts exhausted "
f"({settings.merge_defer_max_attempts})"
)
def _handle_merge_gate_rollback(
task_id, current_stage, repo, work_item_id, branch, reason, result: AdvanceResult
):
"""Rebase conflict / red re-test -> ROLLBACK to development + developer retry.
Mirrors the staging/deploy rollback pattern but is capped by
MAX_DEVELOPER_RETRIES (AC-11 / TC-22: no infinite bounce). The merge lease was
already released by check_branch_mergeable on failure; a defensive holder-aware
release here is a harmless no-op.
"""
update_task_stage(task_id, "development")
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
set_issue_in_progress(work_item_id)
try:
merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on rollback failed: {e}")
notify_qg_failure(task_id, current_stage, "check_branch_mergeable", reason)
plane_add_comment(
work_item_id,
f"❌ Merge-gate FAILED ({reason}). Rolled back to development. "
f"Developer нужен для фикса.",
author="deployer",
)
retry_count = _developer_retry_count(task_id)
if retry_count < MAX_DEVELOPER_RETRIES:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: development\nNote: Merge-gate failed "
f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). "
f"Причина: {reason}."
)
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
result.enqueued_agent = "developer"
result.enqueued_job_id = new_job
logger.info(
f"Task {task_id}: merge-gate FAILED, enqueued developer (job_id={new_job})"
)
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Merge-gate still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
result.alerted = True
logger.error(
f"Task {task_id}: merge-gate FAILED, rolled back deploy-staging -> "
f"development ({reason})"
)
# ---------------------------------------------------------------------------
# ORCH-036: executable self-deploy (Phase A/B/C)
# ---------------------------------------------------------------------------
def _handle_self_deploy_phase_a(
task_id, current_stage, repo, work_item_id, branch, result: AdvanceResult
):
"""Phase A — advance to `deploy` and request a manual approve (no prod deploy).
Staging is green and the branch is mergeable; for the self-hosting repo we do
NOT auto-deploy to prod. Move the task onto the `deploy` stage (so a later
human Approved lands there -> Phase B), set the issue approval-pending and ask
the human to flip the status to Approved. A restart-safe `approve-requested`
marker records that Phase A ran. The merge lease stays HELD.
"""
update_task_stage(task_id, "deploy")
notify_stage_change(task_id, current_stage, "deploy")
result.advanced = True
result.to_stage = "deploy"
result.note = "self-deploy-approval-pending"
if work_item_id:
set_issue_in_review(work_item_id)
# ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before
# arming a fresh approve. A prior FAILED pass clears on rollback, but clearing
# here too guarantees the entry to every new prod-deploy pass starts clean
# (e.g. after a crash/manual intervention), so `initiated`/`result` from an
# earlier attempt can never leak into this one.
self_deploy.clear_state(repo, work_item_id)
self_deploy.write_marker(
repo, work_item_id, self_deploy.APPROVE_REQUESTED, content=str(time.time())
)
if work_item_id:
plane_add_comment(
work_item_id,
"\U0001f7e1 Staging зелёный. Требуется ручной approve для ПРОД-деплоя: "
"смените статус задачи на «Approved», чтобы запустить деплой в прод (8500).",
author="deployer",
)
send_telegram(
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт approve на ПРОД-деплой "
f"(смените статус на Approved)."
)
logger.info(
f"Task {task_id}: self-deploy Phase A — advanced to deploy, "
f"approval-pending (awaiting human Approved)"
)
def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: AdvanceResult):
"""Phase B — a human Approved initiates the DETACHED prod deploy (idempotent).
Idempotency-guard: if the `initiated` marker already exists (double Approved /
duplicate webhook, R-4) this is a no-op. Otherwise launch the detached host
deploy, and ONLY on success record `initiated` + enqueue the finalizer (so a
failed launch can be retried by re-approving). Returns without advancing — the
finalizer (Phase C) records the verdict once the hook finishes.
"""
if self_deploy.has_marker(repo, work_item_id, self_deploy.INITIATED):
result.note = "self-deploy-already-initiated"
logger.info(
f"Task {task_id}: prod deploy already initiated; ignoring repeat Approved"
)
return
ok, msg = self_deploy.initiate_deploy(repo, work_item_id, branch)
if not ok:
result.note = f"self-deploy-initiate-failed: {msg}"
if work_item_id:
plane_add_comment(
work_item_id,
f"⚠️ Не удалось запустить прод-деплой: {msg}. "
"Повторите approve после устранения причины.",
author="deployer",
)
send_telegram(f"⚠️ {work_item_id}: прод-деплой не запустился: {msg}")
logger.error(f"Task {task_id}: self-deploy initiate failed: {msg}")
return
self_deploy.write_marker(
repo, work_item_id, self_deploy.INITIATED, content=str(time.time())
)
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
)
new_job = enqueue_job(
"deploy-finalizer", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.deploy_finalize_delay_s,
)
result.enqueued_agent = "deploy-finalizer"
result.enqueued_job_id = new_job
result.note = "self-deploy-initiated"
if work_item_id:
plane_add_comment(
work_item_id,
"\U0001f680 Прод-деплой стартовал (detached host-процесс). "
"Вердикт будет зафиксирован после health-check.",
author="deployer",
)
send_telegram(f"\U0001f680 {work_item_id}: прод-деплой стартовал. Жду результат.")
logger.info(
f"Task {task_id}: self-deploy Phase B — detached deploy initiated, "
f"finalizer enqueued (job_id={new_job})"
)
def _deploy_finalize_defer_count(task_id: int) -> int:
"""How many times this task's finalizer has already deferred (restart-safe).
Counted from the persisted jobs queue by the defer marker in task_content
(mirrors _merge_defer_count), so a service restart never resets the budget.
"""
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND task_content LIKE '%deploy-finalize defer%'",
(task_id,),
).fetchone()[0]
conn.close()
return n
def run_deploy_finalizer(job: dict):
"""Phase C — deterministic finalizer (reserved-agent `deploy-finalizer`, no LLM).
Claimed by the worker in the NEW container after the prod restart. Reads the
`result` sentinel (hook exit-code written by the host wrapper):
* not written yet & budget left -> DEFER (re-queue with a delay);
* budget exhausted -> set_issue_blocked + Telegram (anti-livelock);
* present -> map exit-code -> deploy_status, write
14-deploy-log.md, then advance_stage(finished_agent="deployer") so the
EXISTING contracts fire: SUCCESS -> terminal-sync deploy->done + release
lease; FAILED -> БАГ-8 rollback deploy->development + set_issue_blocked.
Never raises into the caller (the launcher marks the job done/failed).
"""
task_id = job.get("task_id")
repo = job.get("repo")
conn = get_db()
row = conn.execute(
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
if not row:
logger.error(f"deploy-finalizer: no task row for task_id={task_id}")
return
work_item_id, branch = row[0], row[1]
present, code = self_deploy.read_result(repo, work_item_id)
if not present:
defers = _deploy_finalize_defer_count(task_id)
if defers < settings.deploy_finalize_max_attempts:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize defer "
f"(attempt {defers + 1}/{settings.deploy_finalize_max_attempts}) — "
f"deploy result not ready, retrying after {settings.deploy_finalize_delay_s}s."
)
new_job = enqueue_job(
"deploy-finalizer", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.deploy_finalize_delay_s,
)
logger.info(
f"Task {task_id}: deploy result not ready, finalizer deferred "
f"(job_id={new_job}, attempt {defers + 1}/{settings.deploy_finalize_max_attempts})"
)
else:
if work_item_id:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: deploy result не появился после "
f"{settings.deploy_finalize_max_attempts} попыток. Нужно ручное вмешательство."
)
logger.error(
f"Task {task_id}: deploy-finalize defer attempts exhausted "
f"({settings.deploy_finalize_max_attempts})"
)
return
# Result present -> deterministic verdict.
status = self_deploy.map_exit_code_to_status(code)
self_deploy.write_deploy_log(repo, work_item_id, branch, code, status)
logger.info(
f"Task {task_id}: deploy finalized, hook exit={code} -> deploy_status={status}"
)
if status == "SUCCESS" and work_item_id:
plane_add_comment(
work_item_id,
f"✅ Прод-деплой успешен (health-check OK, exit {code}).",
author="deployer",
)
send_telegram(f"{work_item_id}: прод-деплой успешен (exit {code}).")
# Drive the EXISTING deploy contracts via the gate verdict we just wrote.
advance_stage(
task_id=task_id,
current_stage="deploy",
repo=repo,
work_item_id=work_item_id,
branch=branch,
finished_agent="deployer",
)

View File

@@ -334,6 +334,15 @@ async def handle_pr(payload: dict):
logger.error(f"Task {task_id}: max retries reached, needs manual intervention")
elif action == "closed" and pr.get("merged", False):
# ORCH-043: the branch's PR just merged into main -> release the per-repo
# merge lease this task held from the merge-gate (holder-aware by branch, so
# it can't clobber a lease another task acquired afterwards). Never raises.
try:
from ..merge_gate import release_merge_lease
release_merge_lease(repo_name, head_branch)
except Exception as e: # noqa: BLE001 - defensive, never block the webhook
logger.warning(f"Task {task_id}: merge-lease release on PR-merge failed: {e}")
# BUG 8 (second door): at the deploy stage `done` is gated by the
# deployer's verdict (check_deploy_status via advance_stage), NOT by the
# fact that the PR was merged. The deployer merges the PR at the START of

74
tests/test_config.py Normal file
View File

@@ -0,0 +1,74 @@
"""ORCH-042: Settings.tracker_mode config field.
AC-1: tracker_mode defaults to "edit" and is read from env ORCH_TRACKER_MODE.
Settings is a Pydantic BaseSettings reading env at instantiation, so each case
builds a FRESH Settings() (the process-wide singleton is not mutated).
"""
from src.config import Settings
def test_tracker_mode_defaults_to_edit(monkeypatch):
# No env var -> default "edit" (TC-01 / AC-1).
monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False)
assert Settings().tracker_mode == "edit"
def test_tracker_mode_reads_env_bump(monkeypatch):
# ORCH_TRACKER_MODE=bump -> "bump" (TC-01 / AC-1).
monkeypatch.setenv("ORCH_TRACKER_MODE", "bump")
assert Settings().tracker_mode == "bump"
def test_tracker_mode_reads_env_arbitrary(monkeypatch):
# The field is read verbatim from env; mode RESOLUTION (anything != "bump"
# -> edit) happens in notifications, not here (AC-1/AC-2 split).
monkeypatch.setenv("ORCH_TRACKER_MODE", "garbage")
assert Settings().tracker_mode == "garbage"
# ---------------------------------------------------------------------------
# ORCH-043 / TC-25: merge-gate settings defaults + env override.
# ---------------------------------------------------------------------------
_MERGE_ENV = (
"ORCH_MERGE_GATE_ENABLED",
"ORCH_MERGE_GATE_REPOS",
"ORCH_MERGE_RETEST_TIMEOUT_S",
"ORCH_MERGE_RETEST_TARGET",
"ORCH_MERGE_LOCK_TIMEOUT_S",
"ORCH_MERGE_DEFER_DELAY_S",
"ORCH_MERGE_DEFER_MAX_ATTEMPTS",
)
def test_merge_gate_settings_defaults(monkeypatch):
"""TC-25 / AC-10: documented defaults when no env is set."""
for name in _MERGE_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.merge_gate_enabled is True
assert s.merge_gate_repos == ""
assert s.merge_retest_timeout_s == 600
assert s.merge_retest_target == "tests/"
assert s.merge_lock_timeout_s == 300
assert s.merge_defer_delay_s == 60
assert s.merge_defer_max_attempts == 5
def test_merge_gate_settings_env_override(monkeypatch):
"""TC-25 / AC-10: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_MERGE_GATE_ENABLED", "false")
monkeypatch.setenv("ORCH_MERGE_GATE_REPOS", "orchestrator,enduro-trails")
monkeypatch.setenv("ORCH_MERGE_RETEST_TIMEOUT_S", "120")
monkeypatch.setenv("ORCH_MERGE_RETEST_TARGET", "tests/unit")
monkeypatch.setenv("ORCH_MERGE_LOCK_TIMEOUT_S", "90")
monkeypatch.setenv("ORCH_MERGE_DEFER_DELAY_S", "5")
monkeypatch.setenv("ORCH_MERGE_DEFER_MAX_ATTEMPTS", "9")
s = Settings()
assert s.merge_gate_enabled is False
assert s.merge_gate_repos == "orchestrator,enduro-trails"
assert s.merge_retest_timeout_s == 120
assert s.merge_retest_target == "tests/unit"
assert s.merge_lock_timeout_s == 90
assert s.merge_defer_delay_s == 5
assert s.merge_defer_max_attempts == 9

View File

@@ -0,0 +1,160 @@
"""ORCH-036 TC-04/05/06: the manual-approve gate for the executable self-deploy.
Contract (AC-5, AC-12):
* TC-04 — ``deploy_require_manual_approve`` defaults to True in settings.
* TC-05 — flag true + NO human approve -> the prod hook is NEVER called; the
deploy-staging -> deploy edge only advances the STAGE and requests an approve
(Phase A). ``initiate_deploy`` / ssh subprocess must not be touched.
* TC-06 — flag true + a human Approved -> the prod hook is launched EXACTLY once
(Phase B), idempotent on a repeated Approved (the ``initiated`` marker guards).
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_approve.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# Isolate the sentinel state dirs to a per-test tmp dir.
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
def _pass(*a, **k):
return (True, "ok")
# ---------------------------------------------------------------------------
# TC-04: default flag value
# ---------------------------------------------------------------------------
def test_tc04_manual_approve_default_true():
"""The fresh, un-overridden settings default must be True (safe-by-default)."""
from src.config import Settings
assert Settings().deploy_require_manual_approve is True
# ---------------------------------------------------------------------------
# TC-05: flag true, no approve -> prod hook NOT called (Phase A only)
# ---------------------------------------------------------------------------
def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass},
)
# Spy: the deploy launcher must never run on the staging->deploy edge.
initiate = MagicMock()
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
ssh_run = MagicMock()
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent="deployer",
)
# Phase A: advanced the STAGE to deploy, but requested approve — no prod hook.
assert res.advanced is True
assert res.to_stage == "deploy"
assert _stage(task_id) == "deploy"
assert res.note == "self-deploy-approval-pending"
initiate.assert_not_called()
ssh_run.assert_not_called()
# No deployer job: the human Approved (Phase B) is what triggers the deploy.
assert _jobs() == []
# The restart-safe approve-requested marker was written.
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
# ---------------------------------------------------------------------------
# TC-06: flag true + Approved -> prod hook called exactly once (idempotent)
# ---------------------------------------------------------------------------
def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(stage_engine.settings, "deploy_ssh_host", "mva154")
# Real initiate_deploy, but the ssh subprocess is mocked (rc=0 -> dispatched).
ssh_run = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
task_id = _make_task("deploy") # already on deploy, awaiting Approved
# 1st human Approved -> Phase B initiates the detached deploy.
res1 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent=None,
)
assert res1.note == "self-deploy-initiated"
assert ssh_run.call_count == 1
# The finalizer was enqueued.
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
res2 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent=None,
)
assert res2.note == "self-deploy-already-initiated"
assert ssh_run.call_count == 1 # still exactly one prod deploy

View File

@@ -0,0 +1,47 @@
"""ORCH-036 TC-14: prod deploy is build-ONCE — retag the staging image, no rebuild (AC-7).
The detached prod-deploy command must pass ``SOURCE_IMAGE=<staging-image>`` to the
hook so it retags the staging-validated image onto the prod tag instead of running
``docker build``. We assert the composed ssh command carries the staging source
image and never asks the hook to build.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src import self_deploy # noqa: E402
def test_tc14_deploy_command_retags_staging_image_no_build(monkeypatch):
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(
self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging"
)
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-036", "feature/ORCH-036-x")
remote = cmd[-1]
# The prevalidated staging image is handed to the hook as SOURCE_IMAGE (build-once).
assert "SOURCE_IMAGE=orchestrator-orchestrator-staging" in remote
# No rebuild is requested in the remote command.
assert "docker build" not in remote
assert "--build" not in remote
def test_tc14_hook_retag_branch_present():
"""The hook itself must honour SOURCE_IMAGE by retagging (no rebuild)."""
import pathlib
hook = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "orchestrator-deploy-hook.sh"
text = hook.read_text(encoding="utf-8")
assert 'SOURCE_IMAGE="${SOURCE_IMAGE:-}"' in text
# Build-once retag branch present; the hook never runs `docker build`.
assert 'docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"' in text
# No EXECUTABLE `docker build` line (comments mentioning it are fine).
exec_lines = [
ln.strip() for ln in text.splitlines()
if ln.strip() and not ln.strip().startswith("#")
]
assert not any("docker build" in ln for ln in exec_lines)

View File

@@ -0,0 +1,66 @@
"""ORCH-036 TC-01/02/03: deterministic exit-code -> deploy_status mapping.
The finalizer (Phase C) maps the host-hook exit-code to the machine verdict via a
PURE function (no LLM, no I/O), so it is unit-testable in isolation. Contract
(hook exit-code 0/1/2, AC-1/AC-3): 0 -> SUCCESS; 1 (rolled back), 2 (rollback also
failed), and anything else -> FAILED (fail-closed).
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src import self_deploy # noqa: E402
from src.self_deploy import map_exit_code_to_status, build_deploy_log # noqa: E402
def test_tc01_exit0_maps_to_success():
assert map_exit_code_to_status(0) == "SUCCESS"
def test_tc02_exit1_rolled_back_maps_to_failed():
assert map_exit_code_to_status(1) == "FAILED"
def test_tc03_exit2_rollback_also_failed_maps_to_failed():
assert map_exit_code_to_status(2) == "FAILED"
def test_other_exit_codes_map_to_failed():
for code in (3, 127, 255, -1):
assert map_exit_code_to_status(code) == "FAILED"
def test_non_int_or_none_maps_to_failed_fail_closed():
assert map_exit_code_to_status(None) == "FAILED"
assert map_exit_code_to_status("garbage") == "FAILED"
def test_deploy_log_frontmatter_carries_status():
"""The rendered log must expose deploy_status in YAML frontmatter so the
existing _parse_deploy_status contract (AC-10) reads the right verdict."""
body_ok = build_deploy_log("ORCH-036", 0, "SUCCESS")
assert body_ok.startswith("---\n")
assert "deploy_status: SUCCESS" in body_ok
body_fail = build_deploy_log("ORCH-036", 2, "FAILED")
assert "deploy_status: FAILED" in body_fail
assert "hook_exit_code: 2" in body_fail
def test_clear_state_removes_all_markers_and_is_idempotent(monkeypatch, tmp_path):
"""clear_state wipes the whole work-item state dir (all sentinels) and treats a
missing dir as success, so a re-deploy after rollback starts from a clean slate."""
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
repo, wi = "orchestrator", "ORCH-036"
self_deploy.write_marker(repo, wi, self_deploy.APPROVE_REQUESTED, "t")
self_deploy.write_marker(repo, wi, self_deploy.INITIATED, "t")
self_deploy.write_marker(repo, wi, self_deploy.RESULT, "1")
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is True
assert self_deploy.clear_state(repo, wi) is True
assert self_deploy.has_marker(repo, wi, self_deploy.APPROVE_REQUESTED) is False
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is False
assert self_deploy.has_marker(repo, wi, self_deploy.RESULT) is False
# Idempotent: clearing an already-absent dir is still success (never raises).
assert self_deploy.clear_state(repo, wi) is True

View File

@@ -0,0 +1,118 @@
"""ORCH-036 TC-19: deploy-hook auto-rollback simulation (AC-9).
Drives the REAL ``scripts/orchestrator-deploy-hook.sh`` in a hermetic sandbox:
``docker`` / ``curl`` / ``git`` / ``sleep`` are replaced by PATH-shimmed stubs so
no real infra is touched (and prod is never restarted — INFRA safety). The curl
stub is stateful: the freshly-deployed service is UNHEALTHY for the whole deploy
health-check window, which must trigger the hook's AUTO-ROLLBACK; after the
rollback restart the previous image is HEALTHY again.
Expected hook contract (exit-code 0/1/2):
* health fails -> auto rollback -> previous image healthy -> exit 1 (rolled back);
* the whole run completes well under the 60s MTTR budget (sleeps are shimmed).
"""
import os
import shutil
import stat
import subprocess
import time
import pytest
HOOK = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"scripts", "orchestrator-deploy-hook.sh",
)
pytestmark = pytest.mark.skipif(
shutil.which("bash") is None, reason="bash required for hook simulation"
)
def _write_exec(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
def _setup_sandbox(tmp_path):
"""Create PATH-shimmed docker/curl/git/sleep stubs + a rewritten hook copy."""
binx = tmp_path / "bin"
binx.mkdir()
state = tmp_path / "state"
state.mkdir()
repo = tmp_path / "repo"
repo.mkdir()
cnt = state / "curl_count"
# docker: fake a running service + a recoverable previous image.
_write_exec(str(binx / "docker"), """#!/bin/bash
case "$1" in
compose)
for a in "$@"; do [ "$a" = "ps" ] && { echo "fakecid"; exit 0; }; done
exit 0;;
inspect) echo "sha256:previmage"; exit 0;;
image) exit 0;; # docker image inspect <img> -> found
tag) exit 0;;
*) exit 0;;
esac
""")
# curl: first 20 invocations (10 deploy health attempts x2 calls) UNHEALTHY,
# then HEALTHY (the rolled-back previous image).
_write_exec(str(binx / "curl"), f"""#!/bin/bash
CNT="{cnt}"
n=$(cat "$CNT" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$CNT"
iscode=""
for a in "$@"; do [ "$a" = "-w" ] && iscode=1; done
if [ "$n" -gt 20 ]; then
[ -n "$iscode" ] && echo "200" || echo '{{"status":"ok"}}'
else
[ -n "$iscode" ] && echo "000" || echo ""
fi
exit 0
""")
_write_exec(str(binx / "git"), "#!/bin/bash\nexit 0\n")
# Shim sleep to a no-op so the simulation runs fast (real timing is governed
# by the hook's sleep args; here we only assert the rollback CONTROL FLOW).
_write_exec(str(binx / "sleep"), "#!/bin/bash\nexit 0\n")
# Copy the hook, repointing REPO to the sandbox (avoids the hardcoded prod path).
hook_text = open(HOOK, encoding="utf-8").read()
hook_text = hook_text.replace(
"REPO=/home/slin/repos/orchestrator", f"REPO={repo}"
)
hook_copy = tmp_path / "hook.sh"
_write_exec(str(hook_copy), hook_text)
env = {
**os.environ,
"PATH": f"{binx}:{os.environ['PATH']}",
"LOG": str(state / "hook.log"),
"PREV_IMAGE_FILE": str(state / "prev-image"),
"COMPOSE_PROFILE": "staging",
"TARGET_SERVICE": "orchestrator-staging",
"TARGET_PORT": "8501",
}
return hook_copy, env
def test_tc19_unhealthy_deploy_auto_rolls_back_exit1(tmp_path):
hook_copy, env = _setup_sandbox(tmp_path)
t0 = time.time()
proc = subprocess.run(
["bash", str(hook_copy), "--deploy"],
env=env, capture_output=True, text=True, timeout=60,
)
elapsed = time.time() - t0
# AC-9: unhealthy deploy -> auto rollback succeeded on the previous image -> exit 1.
assert proc.returncode == 1, f"stdout={proc.stdout}\nstderr={proc.stderr}"
out = proc.stdout + proc.stderr
assert "AUTO ROLLBACK" in out
assert "rolled back to previous image successfully" in out
# MTTR well under the 60s budget (sleeps shimmed; control flow only).
assert elapsed < 60

View File

@@ -0,0 +1,102 @@
"""ORCH-036 TC-12/13: no silent deploy — both Plane AND Telegram are notified (AC-6).
The finalizer (Phase C) must announce the prod-deploy outcome on BOTH channels:
* TC-12 — a SUCCESS deploy -> a Plane comment AND a Telegram message.
* TC-13 — a FAILED deploy (rollback) -> a Plane comment AND a Telegram message.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_notif.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _pass(*a, **k):
return (True, "ok")
def _fail(reason):
def _f(*a, **k):
return (False, reason)
return _f
def _run_finalizer(task_id):
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
def test_tc12_success_notifies_plane_and_telegram(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
task_id = _make_task("deploy")
_run_finalizer(task_id)
assert stage_engine.plane_add_comment.called
assert stage_engine.send_telegram.called
def test_tc13_rollback_notifies_plane_and_telegram(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "1")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
_run_finalizer(task_id)
# The БАГ-8 rollback path announces on both channels (no silent failure).
assert stage_engine.send_telegram.called
assert stage_engine.plane_add_comment.called or stage_engine.plane_notify_qg.called

View File

@@ -0,0 +1,141 @@
"""ORCH-036 TC-10: a FAILED prod deploy rolls back deploy -> development (AC-4).
The finalizer (Phase C) reads the hook ``result`` sentinel, maps a non-zero exit
to ``deploy_status: FAILED`` and then drives the EXISTING deploy contract via
``advance_stage(finished_agent="deployer")``. With a FAILED verdict the БАГ-8
rollback fires: deploy -> development, ``set_issue_blocked`` + Telegram alert, and
(for the self-hosting repo) the merge-lease is released so the branch is not
wedged. The hook exit-code -> verdict mapping is unit-tested in
``test_deploy_hook_mapping.py``; here we assert the engine REACTION.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_rollback.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
# The finalizer's deploy-log write touches a git worktree we don't have here;
# the verdict it drives comes from check_deploy_status (monkeypatched below).
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _fail(reason):
def _f(*a, **k):
return (False, reason)
return _f
def test_tc10_failed_deploy_rolls_back_to_development(monkeypatch):
# Hook reported exit 1 (rolled back) -> the host wrapper wrote result=1.
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "1")
# The deploy-log verdict the gate reads is FAILED.
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
# БАГ-8 rollback fired: NOT done, back on development, blocked + alerted.
assert _stage(task_id) == "development"
assert stage_engine.set_issue_blocked.called
assert stage_engine.send_telegram.called
assert stage_engine.set_issue_done.called is False
def test_tc11_re_deploy_after_rollback_not_wedged(monkeypatch):
"""FAILED deploy -> rollback wipes stale markers so a later Phase B re-initiates.
Regression for the re-deploy-after-rollback contract (AC-4/AC-10): markers are
keyed by the (stable) work_item_id, so without cleanup the STALE `initiated` from
the first failed attempt would make Phase B's idempotency-guard a no-op on the
retry and wedge the task on `deploy` forever.
"""
repo, wi, branch = "orchestrator", "ORCH-036", "feature/ORCH-036-x"
# First (failed) pass left BOTH the idempotency-guard and the verdict behind.
self_deploy.write_marker(repo, wi, self_deploy.INITIATED, "123")
self_deploy.write_marker(repo, wi, self_deploy.RESULT, "1")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": repo, "id": 1, "agent": "deploy-finalizer"}
)
# Rollback fired AND the stale deploy-state sentinels were wiped.
assert _stage(task_id) == "development"
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is False
assert self_deploy.has_marker(repo, wi, self_deploy.RESULT) is False
assert self_deploy.read_result(repo, wi) == (False, None)
# Second pass: the task reaches `deploy` again and the human re-approves. Phase B
# must ACTUALLY initiate (no stale `initiated` -> not a no-op), proving the retry
# is no longer wedged.
init = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", init)
result = stage_engine.AdvanceResult(from_stage="deploy")
stage_engine._handle_self_deploy_phase_b(task_id, repo, wi, branch, result)
assert init.called
assert result.note == "self-deploy-initiated"
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is True

View File

@@ -0,0 +1,174 @@
"""ORCH-036 TC-07/08/09: self vs non-self deploy routing (AC-2, AC-11).
* TC-07 — ``is_self_hosting_repo``/``self_deploy_applies`` recognise the
orchestrator repo and reject any other (no regression).
* TC-08 — for the self repo the restart is launched as a DETACHED host process
(ssh + setsid + background), never synchronously inside the agent.
* TC-09 — for a non-self repo (enduro-trails) the deploy keeps the legacy path:
the self-deploy Phase A/B logic does NOT apply.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_routing.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.qg.checks import is_self_hosting_repo # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo, branch, wi):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
def _pass(*a, **k):
return (True, "ok")
# ---------------------------------------------------------------------------
# TC-07: routing predicate
# ---------------------------------------------------------------------------
def test_tc07_is_self_hosting_repo_only_orchestrator():
assert is_self_hosting_repo("orchestrator") is True
assert is_self_hosting_repo("ORCHESTRATOR") is True # case-insensitive
assert is_self_hosting_repo("enduro-trails") is False
assert is_self_hosting_repo("") is False
assert is_self_hosting_repo(None) is False
def test_tc07_self_deploy_applies_mirrors_routing(monkeypatch):
monkeypatch.setattr(self_deploy.settings, "self_deploy_enabled", True)
monkeypatch.setattr(self_deploy.settings, "self_deploy_repos", "")
assert self_deploy.self_deploy_applies("orchestrator") is True
assert self_deploy.self_deploy_applies("enduro-trails") is False
# Global kill-switch wins.
monkeypatch.setattr(self_deploy.settings, "self_deploy_enabled", False)
assert self_deploy.self_deploy_applies("orchestrator") is False
# ---------------------------------------------------------------------------
# TC-08: self repo -> DETACHED host process (ssh + setsid + background)
# ---------------------------------------------------------------------------
def test_tc08_self_repo_launches_detached_host_process(monkeypatch):
"""The deploy command must be an ssh invocation that detaches the hook via
setsid and backgrounds it (`&`), so it survives the prod container restart —
i.e. NOT a synchronous in-agent call."""
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-036", "feature/ORCH-036-x")
assert cmd[0] == "ssh"
assert "slin@mva154" in cmd
remote = cmd[-1]
assert "setsid" in remote # detached session
assert remote.rstrip().endswith("&") # backgrounded
assert "</dev/null" in remote # stdin detached
assert "--deploy" in remote # runs the deploy hook
def test_tc08_initiate_deploy_uses_subprocess_not_blocking(monkeypatch):
"""initiate_deploy dispatches via subprocess (the ssh call returns at once);
a rc=0 means 'detached process launched', not 'deploy finished'."""
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
return MagicMock(returncode=0, stdout="", stderr="")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(self_deploy.subprocess, "run", fake_run)
ok, msg = self_deploy.initiate_deploy("orchestrator", "ORCH-036", "feature/ORCH-036-x")
assert ok is True
assert captured["cmd"][0] == "ssh"
assert "detached" in msg
# ---------------------------------------------------------------------------
# TC-09: non-self repo -> legacy path, self-deploy logic does not apply
# ---------------------------------------------------------------------------
def test_tc09_non_self_repo_uses_legacy_path(monkeypatch):
"""enduro-trails on the deploy-staging -> deploy edge: no Phase A interception,
the deployer is enqueued for the deploy stage exactly as before ORCH-036."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_staging_status": _pass},
) # check_branch_mergeable left REAL -> N/A for non-self repo
# Spy: self-deploy must not be initiated for a non-self repo.
initiate = MagicMock()
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy-staging", "enduro-trails", "feature/ET-009-x", "ET-009")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-009",
"feature/ET-009-x", finished_agent="deployer",
)
assert res.advanced is True
assert _stage(task_id) == "deploy"
assert res.note != "self-deploy-approval-pending"
initiate.assert_not_called()
# Legacy path enqueues the deployer for the deploy stage.
jobs = _jobs()
assert len(jobs) == 1
assert jobs[0]["agent"] == "deployer"
# No self-deploy marker for the non-self repo.
assert not self_deploy.has_marker("enduro-trails", "ET-009", self_deploy.APPROVE_REQUESTED)

View File

@@ -0,0 +1,104 @@
"""ORCH-036 TC-17: a SUCCESS prod deploy preserves the terminal-sync contract (AC-10).
When the finalizer (Phase C) reads exit 0 -> ``deploy_status: SUCCESS`` and drives
``advance_stage(finished_agent="deployer")``, the EXISTING deploy->done transition
must still fire unchanged: stage becomes ``done``, ``set_issue_done`` is called, no
agent is launched, and the merge-lease is released (terminal-sync, ORCH-43/БАГ-8
contract). ORCH-036 only changes HOW the verdict is produced, never the contract.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_terminal.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
conn.close()
return [r[0] for r in rows]
def _pass(*a, **k):
return (True, "ok")
def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
# Hook reported exit 0 -> the host wrapper wrote result=0.
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
# Spy the merge-lease release to confirm the terminal-sync still frees it.
release = MagicMock()
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", release)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
assert stage_engine.set_issue_done.called
# The merge-lease is released on the deploy->done terminal-sync.
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
# No agent is launched leaving deploy (terminal).
assert _jobs() == []

View File

@@ -1,298 +0,0 @@
"""ORCH-044 (P3): empty run log / no result-JSON at exit 0 == failure.
claude can exit 0 yet leave an empty (or JSON-less) run log — e.g. it died fast
because the session was logged out, or a flag silenced stdout. Before ORCH-044
that looked identical to success: job -> done, stage auto-advanced. Now the
launcher validates the result; only (exit 0 AND valid result-JSON) is a success.
No real claude/Popen is spawned. The git/usage/notify side effects of
_monitor_agent are stubbed; DB is a fresh per-test sqlite.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_empty_log.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
import src.db as db
from src.db import init_db, enqueue_job, claim_next_job, get_job
from src import preflight
from src.agents.launcher import AgentLauncher
VALID_RESULT_LOG = (
"some preamble text from the agent run...\n"
'{"type":"result","subtype":"success","usage":'
'{"input_tokens":120,"output_tokens":45},"total_cost_usd":0.12}\n'
)
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setattr(db.settings, "db_path", str(tmp_path / "res.db"))
init_db()
preflight.reset_cache()
yield
# ===========================================================================
# _validate_result — the result-JSON contract (TR-3.1)
# ===========================================================================
class TestValidateResult:
def test_missing_path(self):
ok, reason = AgentLauncher._validate_result(None)
assert ok is False
def test_missing_file(self, tmp_path):
ok, reason = AgentLauncher._validate_result(str(tmp_path / "nope.log"))
assert ok is False
assert "missing" in reason.lower()
def test_empty_file(self, tmp_path):
p = tmp_path / "empty.log"
p.write_text("")
ok, reason = AgentLauncher._validate_result(str(p))
assert ok is False
assert "empty" in reason.lower()
def test_whitespace_only(self, tmp_path):
p = tmp_path / "ws.log"
p.write_text(" \n\t\n")
ok, _ = AgentLauncher._validate_result(str(p))
assert ok is False
def test_no_json(self, tmp_path):
p = tmp_path / "garbage.log"
p.write_text("this is not json at all, just noise\n")
ok, reason = AgentLauncher._validate_result(str(p))
assert ok is False
assert "json" in reason.lower()
def test_valid_result_json(self, tmp_path):
p = tmp_path / "good.log"
p.write_text(VALID_RESULT_LOG)
ok, _ = AgentLauncher._validate_result(str(p))
assert ok is True
# ===========================================================================
# _finalize_job — job state under result_ok (TC-12/13/15/16/17)
# ===========================================================================
class TestFinalizeJobResultOk:
def _spy_telegram(self, monkeypatch):
sent = []
monkeypatch.setattr("src.notifications.send_telegram",
lambda *a, **k: sent.append(a[0] if a else ""))
return sent
# TC-15 / AC-13: valid result -> done (no regression).
def test_valid_result_done(self, tmp_path, monkeypatch):
self._spy_telegram(monkeypatch)
log = tmp_path / "1.log"
log.write_text(VALID_RESULT_LOG)
jid = enqueue_job("developer", "r")
claim_next_job()
AgentLauncher()._finalize_job(jid, "developer", run_id=1, exit_code=0,
output_path=str(log), result_ok=True)
assert get_job(jid)["status"] == "done"
# TC-12 / AC-10: exit 0 + empty log -> NOT done; terminal failed + alert.
def test_empty_log_exit0_terminal_failed_alerts(self, tmp_path, monkeypatch):
sent = self._spy_telegram(monkeypatch)
log = tmp_path / "2.log"
log.write_text("") # 0 bytes
# max_attempts=1 -> after the claim (attempts=1) the budget is spent ->
# the permanent path goes straight to 'failed' and alerts.
jid = enqueue_job("developer", "r", max_attempts=1)
claim_next_job()
AgentLauncher()._finalize_job(jid, "developer", run_id=2, exit_code=0,
output_path=str(log), result_ok=False)
job = get_job(jid)
assert job["status"] == "failed"
assert job["status"] != "done"
assert "empty run log" in (job["error"] or "")
assert sent, "a Telegram alert must be sent on terminal failure"
# TC-13 / AC-11: exit 0 + JSON-less log -> failure (here: requeue).
def test_garbage_log_exit0_not_done(self, tmp_path, monkeypatch):
self._spy_telegram(monkeypatch)
log = tmp_path / "3.log"
log.write_text("noise, no json here\n")
jid = enqueue_job("developer", "r", max_attempts=2)
claim_next_job()
AgentLauncher()._finalize_job(jid, "developer", run_id=3, exit_code=0,
output_path=str(log), result_ok=False)
job = get_job(jid)
assert job["status"] != "done"
assert job["status"] == "queued" # retry budget remained
assert "no result JSON" in (job["error"] or "")
# TC-16 / AC-14: exit 0 + empty log never leaves the job 'running'.
def test_never_running_after_empty_result(self, tmp_path, monkeypatch):
self._spy_telegram(monkeypatch)
log = tmp_path / "4.log"
log.write_text("")
jid = enqueue_job("developer", "r", max_attempts=2)
claim_next_job()
assert get_job(jid)["status"] == "running" # claimed
AgentLauncher()._finalize_job(jid, "developer", run_id=4, exit_code=0,
output_path=str(log), result_ok=False)
assert get_job(jid)["status"] in ("failed", "queued")
# TC-17 / TR-3.3: empty result defaults to permanent (no backoff, no
# transient budget burn).
def test_empty_result_defaults_permanent(self, tmp_path, monkeypatch):
self._spy_telegram(monkeypatch)
log = tmp_path / "5.log"
log.write_text("") # no transient marker
jid = enqueue_job("developer", "r", max_attempts=2)
claim_next_job()
AgentLauncher()._finalize_job(jid, "developer", run_id=5, exit_code=0,
output_path=str(log), result_ok=False)
job = get_job(jid)
assert job["status"] == "queued"
assert job["transient_attempts"] == 0 # NOT transient
assert job["available_at"] is None # no backoff gate
# TC-17 / TR-3.3: a transient marker in the log routes to the transient path.
def test_empty_result_with_transient_marker_goes_transient(self, tmp_path, monkeypatch):
self._spy_telegram(monkeypatch)
log = tmp_path / "6.log"
log.write_text("overloaded_error: 429 rate limit. Retry-After: 12\n")
jid = enqueue_job("developer", "r", max_attempts=2)
claim_next_job()
AgentLauncher()._finalize_job(jid, "developer", run_id=6, exit_code=0,
output_path=str(log), result_ok=False)
job = get_job(jid)
assert job["status"] == "queued"
assert job["transient_attempts"] == 1 # transient path taken
assert job["available_at"] is not None # backoff gate set
# ===========================================================================
# _monitor_agent — success gating (TC-14/15) + auth-marker reset (P1b)
# ===========================================================================
class _FakeProc:
def __init__(self, exit_code):
self._ec = exit_code
self.pid = 4242
def wait(self):
return self._ec
def _seed_task_and_run(repo, branch, agent="developer", work_item_id="ORCH-001"):
conn = db.get_db()
conn.execute(
"INSERT INTO tasks (work_item_id, repo, branch, stage) VALUES (?,?,?,?)",
(work_item_id, repo, branch, "development"),
)
cur = conn.execute(
"INSERT INTO agent_runs (task_id, agent) VALUES ((SELECT id FROM tasks "
"WHERE repo=? AND branch=?), ?)",
(repo, branch, agent),
)
run_id = cur.lastrowid
conn.commit()
conn.close()
return run_id
class TestMonitorAgentGating:
def _patch_monitor_env(self, monkeypatch, tmp_path):
"""Stub the heavy side effects of _monitor_agent (git/usage/notify)."""
monkeypatch.setattr("src.agents.launcher.notify_agent_finished",
lambda *a, **k: None)
monkeypatch.setattr("src.agents.launcher.get_worktree_path",
lambda repo, branch: str(tmp_path))
class _R:
returncode = 0
stdout = "" # "no changes to commit" -> skips git add/commit/push
stderr = ""
monkeypatch.setattr("src.agents.launcher.subprocess.run",
lambda *a, **k: _R())
def test_success_advances_and_comments(self, tmp_path, monkeypatch):
self._patch_monitor_env(monkeypatch, tmp_path)
run_id = _seed_task_and_run("r", "feature/x")
log = tmp_path / f"{run_id}.log"
log.write_text(VALID_RESULT_LOG)
spy = {"post": 0, "advance": 0, "finalize": None, "alert": 0}
monkeypatch.setattr("src.notifications.send_telegram",
lambda *a, **k: spy.__setitem__("alert", spy["alert"] + 1))
lr = AgentLauncher()
monkeypatch.setattr(lr, "_post_usage_comments",
lambda *a, **k: spy.__setitem__("post", spy["post"] + 1))
monkeypatch.setattr(lr, "_try_advance_stage",
lambda *a, **k: spy.__setitem__("advance", spy["advance"] + 1))
monkeypatch.setattr(lr, "_finalize_job",
lambda *a, **k: spy.__setitem__("finalize", k.get("result_ok")))
lr._monitor_agent(_FakeProc(0), run_id, "developer", "r", "feature/x",
output_path=str(log), log_fh=None, job_id=99)
assert spy["post"] == 1
assert spy["advance"] == 1
assert spy["finalize"] is True
assert spy["alert"] == 0 # no empty-result alert on a valid run
# TC-14 / AC-12: empty result -> no advance, no success comment, alert sent.
def test_empty_result_suppresses_advance_and_comment(self, tmp_path, monkeypatch):
self._patch_monitor_env(monkeypatch, tmp_path)
run_id = _seed_task_and_run("r", "feature/y")
log = tmp_path / f"{run_id}.log"
log.write_text("") # empty -> invalid result
spy = {"post": 0, "advance": 0, "finalize": None, "alert": 0}
monkeypatch.setattr("src.notifications.send_telegram",
lambda *a, **k: spy.__setitem__("alert", spy["alert"] + 1))
lr = AgentLauncher()
monkeypatch.setattr(lr, "_post_usage_comments",
lambda *a, **k: spy.__setitem__("post", spy["post"] + 1))
monkeypatch.setattr(lr, "_try_advance_stage",
lambda *a, **k: spy.__setitem__("advance", spy["advance"] + 1))
monkeypatch.setattr(lr, "_finalize_job",
lambda *a, **k: spy.__setitem__("finalize", k.get("result_ok")))
lr._monitor_agent(_FakeProc(0), run_id, "developer", "r", "feature/y",
output_path=str(log), log_fh=None, job_id=99)
assert spy["post"] == 0 # no success comment
assert spy["advance"] == 0 # stage NOT advanced
assert spy["finalize"] is False # finalize told the result was invalid
assert spy["alert"] == 1 # empty-result alert fired
# ===========================================================================
# _handle_auth_marker — post-factum auth detection resets preflight cache (P1b)
# ===========================================================================
class TestAuthMarkerHandling:
def test_auth_marker_resets_preflight_cache(self, tmp_path, monkeypatch):
log = tmp_path / "auth.log"
log.write_text("Error: Not logged in. Please run /login\n")
reset = {"n": 0}
monkeypatch.setattr(preflight, "reset_cache",
lambda: reset.__setitem__("n", reset["n"] + 1))
found = AgentLauncher()._handle_auth_marker(str(log))
assert found is True
assert reset["n"] == 1
def test_no_auth_marker_no_reset(self, tmp_path, monkeypatch):
log = tmp_path / "plain.log"
log.write_text("Traceback: ValueError somewhere\n")
reset = {"n": 0}
monkeypatch.setattr(preflight, "reset_cache",
lambda: reset.__setitem__("n", reset["n"] + 1))
found = AgentLauncher()._handle_auth_marker(str(log))
assert found is False
assert reset["n"] == 0

301
tests/test_merge_gate.py Normal file
View File

@@ -0,0 +1,301 @@
"""ORCH-043: tests for src/merge_gate core (TC-01..TC-11).
Git tests use REAL local repos in tmp (a bare 'origin' + a main clone), so
fetch / merge-base / rebase / push --force-with-lease are exercised without
network, mirroring tests/test_git_worktree.py. The re-test (pytest) and lease
units are isolated with monkeypatch / tmp files.
"""
import json
import os
import subprocess
import tempfile
import time
import pytest
# Env before importing app modules (same convention as the other suites).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
from src import git_worktree, merge_gate # noqa: E402
def _git(cwd, *args):
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
def _origin_sha(origin, ref):
return _git(str(origin), "rev-parse", ref).stdout.strip()
@pytest.fixture
def repos(tmp_path, monkeypatch):
"""Bare 'origin' (main@C1) + main clone + two feature branches branched from C0.
Layout:
C0 README.md
feature/behind : C0 + adds f.txt (rebases cleanly onto C1)
feature/conflict : C0 + edits README.md (textual conflict with C1)
feature/uptodate : branched from C1 (already contains origin/main)
main C1 : edits README.md + adds other.txt
Returns (repo_name, origin_path).
"""
repo = "orchestrator"
repos_dir = tmp_path / "repos"
wt_dir = tmp_path / "repos" / "_wt"
repos_dir.mkdir(parents=True)
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
_git(str(seed), "init", "-b", "main")
_git(str(seed), "config", "user.email", "t@t")
_git(str(seed), "config", "user.name", "t")
(seed / "README.md").write_text("base\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "C0")
_git(str(seed), "remote", "add", "origin", str(origin))
_git(str(seed), "push", "origin", "main")
# Branches off C0.
_git(str(seed), "checkout", "-b", "feature/behind")
(seed / "f.txt").write_text("feature\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "feat: add f.txt")
_git(str(seed), "push", "origin", "feature/behind")
_git(str(seed), "checkout", "main")
_git(str(seed), "checkout", "-b", "feature/conflict")
(seed / "README.md").write_text("feature readme\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "feat: edit README")
_git(str(seed), "push", "origin", "feature/conflict")
# Advance main to C1.
_git(str(seed), "checkout", "main")
(seed / "README.md").write_text("main v2\n")
(seed / "other.txt").write_text("main change\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "C1")
_git(str(seed), "push", "origin", "main")
# Branch that already contains C1.
_git(str(seed), "checkout", "-b", "feature/uptodate")
(seed / "g.txt").write_text("uptodate\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "feat: on top of C1")
_git(str(seed), "push", "origin", "feature/uptodate")
# Main clone at repos_dir/<repo>.
main_clone = repos_dir / repo
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
_git(str(main_clone), "config", "user.email", "t@t")
_git(str(main_clone), "config", "user.name", "t")
return repo, origin
# ---------------------------------------------------------------------------
# TC-01..03: branch_is_behind_main
# ---------------------------------------------------------------------------
def test_tc01_behind_when_main_ahead(repos):
repo, _ = repos
assert merge_gate.branch_is_behind_main(repo, "feature/behind") is True
def test_tc02_not_behind_when_branch_contains_main(repos):
repo, _ = repos
assert merge_gate.branch_is_behind_main(repo, "feature/uptodate") is False
def test_tc03_never_raises_on_bad_repo(monkeypatch, tmp_path):
# Point repos_dir at an empty dir -> ensure_worktree raises -> caught -> True.
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path / "nope"))
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(tmp_path / "nope"))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt"))
result = merge_gate.branch_is_behind_main("orchestrator", "feature/x")
assert result is True # safe bool, not an exception
# ---------------------------------------------------------------------------
# TC-04..06: auto_rebase_onto_main
# ---------------------------------------------------------------------------
def test_tc04_clean_catchup_pushes_with_lease(repos):
repo, origin = repos
main_before = _origin_sha(origin, "main")
ok, reason = merge_gate.auto_rebase_onto_main(repo, "feature/behind")
assert ok is True, reason
# origin/main must be UNTOUCHED (AC-7).
assert _origin_sha(origin, "main") == main_before
# The pushed branch now contains origin/main (origin/main is its ancestor).
rc = subprocess.run(
["git", "-C", str(origin), "merge-base", "--is-ancestor",
"main", "feature/behind"],
capture_output=True,
).returncode
assert rc == 0
# And it carries main's new file plus its own.
assert _git(str(origin), "cat-file", "-e", "feature/behind:other.txt").returncode == 0
assert _git(str(origin), "cat-file", "-e", "feature/behind:f.txt").returncode == 0
def test_tc05_conflict_aborts_clean_and_reports(repos):
repo, origin = repos
main_before = _origin_sha(origin, "main")
branch_before = _origin_sha(origin, "feature/conflict")
ok, reason = merge_gate.auto_rebase_onto_main(repo, "feature/conflict")
assert ok is False
assert "rebase conflict" in reason
# main untouched, branch NOT force-pushed past the conflict.
assert _origin_sha(origin, "main") == main_before
assert _origin_sha(origin, "feature/conflict") == branch_before
# Worktree left clean (no rebase in progress).
wt = git_worktree.get_worktree_path(repo, "feature/conflict")
assert not os.path.isdir(os.path.join(wt, ".git", "rebase-merge"))
assert not os.path.isdir(os.path.join(wt, ".git", "rebase-apply"))
def test_tc06_never_pushes_main(repos, monkeypatch):
repo, origin = repos
calls = []
real_run = subprocess.run
def _spy(cmd, *a, **k):
if isinstance(cmd, list):
calls.append(cmd)
return real_run(cmd, *a, **k)
monkeypatch.setattr(merge_gate.subprocess, "run", _spy)
merge_gate.auto_rebase_onto_main(repo, "feature/behind")
pushes = [c for c in calls if "push" in c]
assert pushes, "expected at least one push"
for c in pushes:
# Never push main; force only via --force-with-lease on the task branch.
assert "main" not in c, f"push touched main: {c}"
assert "--force-with-lease" in c
assert "feature/behind" in c
# Hard force must never be used.
assert "--force" not in c or "--force-with-lease" in c
assert "-f" not in c
# ---------------------------------------------------------------------------
# TC-07..09: retest_branch (isolated from real pytest)
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_worktree(tmp_path, monkeypatch):
wt = tmp_path / "wt"
wt.mkdir()
monkeypatch.setattr(merge_gate, "get_worktree_path", lambda repo, branch: str(wt))
return str(wt)
def test_tc07_retest_green(fake_worktree, monkeypatch):
monkeypatch.setattr(
merge_gate.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, "1 passed", ""),
)
ok, reason = merge_gate.retest_branch("orchestrator", "feature/x")
assert ok is True
assert reason == "re-test green"
def test_tc08_retest_red_with_tail(fake_worktree, monkeypatch):
monkeypatch.setattr(
merge_gate.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(
a, 1, "FAILED tests/test_x.py::t - AssertionError\n1 failed", ""
),
)
ok, reason = merge_gate.retest_branch("orchestrator", "feature/x")
assert ok is False
assert "re-test failed" in reason
assert "AssertionError" in reason # output tail embedded
def test_tc09_retest_timeout(fake_worktree, monkeypatch):
def _boom(*a, **k):
raise subprocess.TimeoutExpired(cmd="pytest", timeout=1)
monkeypatch.setattr(merge_gate.settings, "merge_retest_timeout_s", 1)
monkeypatch.setattr(merge_gate.subprocess, "run", _boom)
ok, reason = merge_gate.retest_branch("orchestrator", "feature/x")
assert ok is False
assert "re-test timeout" in reason
# ---------------------------------------------------------------------------
# TC-10..11: merge-lease (serialisation)
# ---------------------------------------------------------------------------
@pytest.fixture
def lease_dir(tmp_path, monkeypatch):
d = tmp_path / "repos"
d.mkdir()
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(d))
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
return d
def test_tc10_second_acquire_busy_until_released(lease_dir):
repo = "orchestrator"
ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1")
assert ok is True
# A different branch cannot acquire while held.
ok2, reason2 = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2")
assert ok2 is False
assert reason2 == "merge-lock busy"
# Same holder is idempotent.
ok_self, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1")
assert ok_self is True
# Release (holder-aware) frees it for B.
merge_gate.release_merge_lease(repo, "feature/A")
ok3, _ = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2")
assert ok3 is True
def test_tc10_release_is_holder_aware(lease_dir):
repo = "orchestrator"
merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1")
# A stale release from a DIFFERENT branch must NOT drop A's lease.
merge_gate.release_merge_lease(repo, "feature/OTHER")
ok, reason = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2")
assert ok is False and reason == "merge-lock busy"
def test_tc11_stale_lease_is_reclaimed(lease_dir, monkeypatch):
repo = "orchestrator"
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 10)
# Write a lease that is older than the timeout (orphaned by a dead process).
path = merge_gate._lease_path(repo)
with open(path, "w", encoding="utf-8") as f:
json.dump(
{"branch": "feature/dead", "acquired_at": time.time() - 999, "pid": 1},
f,
)
ok, reason = merge_gate.acquire_merge_lease(repo, "feature/new", "ORCH-9")
assert ok is True
assert "reclaimed" in reason
# The new holder now owns it.
held = json.load(open(path, encoding="utf-8"))
assert held["branch"] == "feature/new"
def test_tc11_release_missing_is_noop(lease_dir):
# Releasing a non-existent lease never raises.
merge_gate.release_merge_lease("orchestrator", "feature/none")
merge_gate.release_merge_lease("orchestrator") # force form

View File

@@ -0,0 +1,150 @@
"""ORCH-043 / TC-24: the parallel-merge race the gate exists to prevent.
Scenario (two green branches in ONE repo, the self-hosting risk, ТЗ §1):
* main is at C1 because branch A already merged.
* branch B was validated against C0 (the main it branched from) and is GREEN
there — but B has NOT seen A's change. A blind merge of B could break main
(semantic conflict): B is "green" yet stale.
The merge-gate makes this deterministic:
1. While A holds the merge-lease, B's gate sees "merge-lock busy" -> DEFER
(serialisation: no two catch-up+merge sequences interleave).
2. After A releases, B acquires the lease, rebases onto the CURRENT origin/main
(C1) and re-tests the COMBINED tree:
- re-test GREEN -> gate passes, lease HELD -> B is safe to merge; main stays green.
- re-test RED -> gate fails, lease RELEASED -> B rolls back to development;
main is NEVER touched.
origin/main's SHA is asserted unchanged throughout — the gate never pushes main.
Real local git (bare origin + clone), real file lease; only the pytest re-test is
stubbed (its real behaviour lives in test_merge_gate.py::retest_branch tests).
"""
import os
import subprocess
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate_race.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
from src import git_worktree, merge_gate # noqa: E402
from src.qg import checks as qg # noqa: E402
from src.qg.checks import check_branch_mergeable # noqa: E402
def _git(cwd, *args):
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
@pytest.fixture
def race_repo(tmp_path, monkeypatch):
"""Bare origin at C1 (A merged) + clone + feature/B branched from C0.
Returns (repo, origin_path). feature/B rebases cleanly onto origin/main.
The gate is forced REAL for this repo via merge_gate_repos.
"""
repo = "orchestrator"
repos_dir = tmp_path / "repos"
wt_dir = tmp_path / "repos" / "_wt"
repos_dir.mkdir(parents=True)
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
monkeypatch.setattr(qg.settings, "merge_gate_repos", repo)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
_git(str(seed), "init", "-b", "main")
_git(str(seed), "config", "user.email", "t@t")
_git(str(seed), "config", "user.name", "t")
(seed / "README.md").write_text("base\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "C0")
_git(str(seed), "remote", "add", "origin", str(origin))
_git(str(seed), "push", "origin", "main")
# B branches off C0, adds an isolated file (clean rebase onto C1).
_git(str(seed), "checkout", "-b", "feature/B")
(seed / "b.txt").write_text("from B\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "feat(B): add b.txt")
_git(str(seed), "push", "origin", "feature/B")
# A merged -> main advances to C1 (touches a DIFFERENT file: no textual conflict).
_git(str(seed), "checkout", "main")
(seed / "a.txt").write_text("from A\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "C1 (A merged)")
_git(str(seed), "push", "origin", "main")
main_clone = repos_dir / repo
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
_git(str(main_clone), "config", "user.email", "t@t")
_git(str(main_clone), "config", "user.name", "t")
return repo, origin
def _origin_main_sha(origin):
return _git(str(origin), "rev-parse", "main").stdout.strip()
def test_tc24_busy_lock_serialises_then_green_catch_up_is_safe(race_repo, monkeypatch):
"""A holds the lease -> B defers; after release B catches up + green re-test ->
safe merge (lease held), and origin/main is never pushed by the gate."""
repo, origin = race_repo
main_before = _origin_main_sha(origin)
# A is mid-merge: it holds the lease.
ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-A")
assert ok is True
# B's gate must DEFER (serialisation), touching nothing.
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
assert passed is False
assert reason == "merge-lock busy"
assert _origin_main_sha(origin) == main_before # main untouched
# A finishes and releases.
merge_gate.release_merge_lease(repo, "feature/A")
# B catches up: real rebase onto C1, GREEN re-test -> pass, lease HELD.
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "re-test green"))
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
assert passed is True
assert reason == "rebased onto main, re-test green"
# The gate rebased+pushed ONLY the task branch; origin/main is unchanged.
assert _origin_main_sha(origin) == main_before
# feature/B now contains C1 (a.txt) on origin after the force-with-lease push.
assert "a.txt" in _git(str(origin), "ls-tree", "--name-only", "feature/B").stdout
# Lease is HELD by B until the actual merge.
held = merge_gate._read_lease(merge_gate._lease_path(repo))
assert held is not None and held.get("branch") == "feature/B"
def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkeypatch):
"""B catches up but the COMBINED tree is red -> gate fails, lease released,
origin/main never touched (B will roll back to development upstream)."""
repo, origin = race_repo
main_before = _origin_main_sha(origin)
monkeypatch.setattr(
merge_gate, "retest_branch",
lambda r, b: (False, "re-test failed: ...1 failed, 9 passed"),
)
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
assert passed is False
assert reason.startswith("re-test failed after rebase:")
# main is still green / untouched.
assert _origin_main_sha(origin) == main_before
# The lease was released on failure (a later task can proceed).
assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None

View File

@@ -0,0 +1,112 @@
"""ORCH-040: контейнер/агенты бегут под uid:gid хоста (1000:1000), не root.
Валидируют docker-compose.yml (Вариант 1 из ADR-001) и согласованность с
HOME, который форсит launcher. Чистые конфиг-тесты: парсят YAML и текст
launcher, без запуска docker/агентов.
См. docs/work-items/ORCH-040/{02-trz.md,03-acceptance-criteria.md,
04-test-plan.yaml} и 06-adr/ADR-001-run-agents-as-host-uid.md.
"""
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
COMPOSE_PATH = REPO_ROOT / "docker-compose.yml"
LAUNCHER_PATH = REPO_ROOT / "src" / "agents" / "launcher.py"
# Сервисы, которые исполняют конвейер и обязаны бежать под uid хоста.
PIPELINE_SERVICES = ("orchestrator", "orchestrator-staging")
# Единый HOME агента (форсится launcher'ом); под ним должны лежать .ssh/.claude.
EXPECTED_HOME = "/home/slin"
@pytest.fixture(scope="module")
def compose() -> dict:
"""Распарсенный docker-compose.yml."""
with COMPOSE_PATH.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
assert "services" in data, "docker-compose.yml без секции services"
return data
def _service(compose: dict, name: str) -> dict:
services = compose["services"]
assert name in services, f"сервис {name} отсутствует в docker-compose.yml"
return services[name]
def _ssh_mount_target(service: dict) -> str:
"""Target SSH-маунта (источник .orchestrator-ssh) для сервиса."""
for vol in service.get("volumes", []):
# формат "src:target[:mode]"
parts = vol.split(":")
src = parts[0]
if src.endswith(".orchestrator-ssh"):
assert len(parts) >= 2, f"SSH-маунт без target: {vol}"
return parts[1]
raise AssertionError("SSH-маунт (.orchestrator-ssh) не найден в volumes")
# --- TC-01: user: "1000:1000" в обоих сервисах ---------------------------------
@pytest.mark.parametrize("name", PIPELINE_SERVICES)
def test_tc01_service_runs_as_host_uid(compose, name):
"""TC-01/AC-1: сервис бежит под uid:gid хоста 1000:1000, а не root."""
service = _service(compose, name)
assert "user" in service, f"{name}: отсутствует ключ user (нужен '1000:1000')"
# docker допускает int или строку; нормализуем к строке.
assert str(service["user"]) == "1000:1000", (
f"{name}: user={service['user']!r}, ожидалось '1000:1000'"
)
# --- TC-02: group_add сохраняет "999" (docker.sock — МИНА 1) --------------------
@pytest.mark.parametrize("name", PIPELINE_SERVICES)
def test_tc02_group_add_keeps_docker_gid(compose, name):
"""TC-02/AC-4: group_add содержит 999 (доступ к docker.sock не потерян)."""
service = _service(compose, name)
group_add = service.get("group_add", [])
normalized = {str(g) for g in group_add}
assert "999" in normalized, (
f"{name}: group_add={group_add!r}, должен содержать '999' (docker.sock)"
)
# --- TC-03: SSH-маунт согласован с HOME (под /home/slin, не /root) --------------
@pytest.mark.parametrize("name", PIPELINE_SERVICES)
def test_tc03_ssh_mount_under_home(compose, name):
"""TC-03/AC-5: target SSH-маунта лежит в HOME агента (/home/slin/.ssh)."""
service = _service(compose, name)
target = _ssh_mount_target(service)
assert target == f"{EXPECTED_HOME}/.ssh", (
f"{name}: SSH target={target!r}, ожидалось '{EXPECTED_HOME}/.ssh' "
f"(не /root/.ssh — иначе рассинхрон с HOME агента)"
)
assert not target.startswith("/root/"), (
f"{name}: SSH target указывает на чужой HOME (/root): {target}"
)
# --- TC-04: HOME launcher'а совместим с SSH/claude-маунтами ---------------------
def test_tc04_launcher_home_matches_mounts(compose):
"""TC-04: HOME, форсимый launcher'ом, совпадает с базой SSH/claude-маунтов.
Нет рассинхрона HOME vs uid: и env Popen, и git_env, и target SSH-маунта
все указывают на /home/slin.
"""
source = LAUNCHER_PATH.read_text(encoding="utf-8")
# launcher форсит HOME в двух местах (env Popen и git_env).
occurrences = source.count(f'"HOME": "{EXPECTED_HOME}"')
assert occurrences >= 2, (
f"launcher.py: ожидалось >=2 форсинга HOME={EXPECTED_HOME!r}, "
f"найдено {occurrences}"
)
# И SSH-маунты обоих сервисов ведут в этот же HOME.
for name in PIPELINE_SERVICES:
target = _ssh_mount_target(_service(compose, name))
assert target.startswith(f"{EXPECTED_HOME}/"), (
f"{name}: SSH target {target} не под HOME агента {EXPECTED_HOME}"
)

View File

@@ -1,246 +0,0 @@
"""ORCH-044 (P1): token-free preflight auth gate.
`claude --version` answers even when claude is logged OUT, so version-only
preflight was blind to auth. These tests cover the new local credentials check:
missing / expired / valid token, broken JSON fail-safe, no network, caching,
HOME-correct path resolution, and the queue-worker claim gate.
No real claude/Popen is spawned: `_run_version` is stubbed and credentials live
in tmp files. DB is a fresh per-test sqlite (mirrors tests/test_resilience.py).
"""
import os
import json
import socket
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_preflight_auth.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
import src.db as db
from src.db import init_db, enqueue_job, get_job, count_running_jobs
from src import preflight
from src.queue_worker import QueueWorker
from src.agents.launcher import AgentLauncher
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setattr(db.settings, "db_path", str(tmp_path / "res.db"))
init_db()
preflight.reset_cache()
# auth check on by default; large TTL unless a test overrides it.
monkeypatch.setattr(preflight.settings, "preflight_check_auth", True)
yield
def _fake_bin(monkeypatch, tmp_path):
"""A bin path that exists + a --version that always succeeds (auth-agnostic)."""
b = tmp_path / "claude"
b.write_text("#!/bin/sh\necho v1\n")
monkeypatch.setattr(preflight, "_claude_bin", lambda: str(b))
monkeypatch.setattr(preflight, "_run_version", lambda b: (True, "1.2.3"))
def _write_creds(tmp_path, *, expires_ms=None, access_token="tok", oauth=True,
raw=None):
path = tmp_path / ".credentials.json"
if raw is not None:
path.write_text(raw)
return path
body = {}
if oauth:
oa = {"accessToken": access_token}
if expires_ms is not None:
oa["expiresAt"] = expires_ms
body["claudeAiOauth"] = oa
path.write_text(json.dumps(body))
return path
# ---------------------------------------------------------------------------
# TC-01 / AC-1: not logged in (no credentials file) -> FAIL
# ---------------------------------------------------------------------------
def test_missing_credentials_fails(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
monkeypatch.setattr(preflight, "_credentials_path",
lambda: str(tmp_path / "nope.json"))
ok, reason = preflight.check(force=True)
assert ok is False
assert "logged in" in reason.lower() or "credentials" in reason.lower()
# ---------------------------------------------------------------------------
# TC-02 / AC-2: expired OAuth token -> FAIL
# ---------------------------------------------------------------------------
def test_expired_token_fails(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
past = (int(__import__("time").time()) - 3600) * 1000 # 1h ago, epoch ms
creds = _write_creds(tmp_path, expires_ms=past)
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
ok, reason = preflight.check(force=True)
assert ok is False
assert "expired" in reason.lower()
# ---------------------------------------------------------------------------
# TC-03 / AC-3: valid login -> OK (no regression)
# ---------------------------------------------------------------------------
def test_valid_login_ok(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
future = (int(__import__("time").time()) + 3600) * 1000 # 1h ahead
creds = _write_creds(tmp_path, expires_ms=future)
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
ok, reason = preflight.check(force=True)
assert ok is True
def test_token_without_expiry_is_ok(monkeypatch, tmp_path):
# accessToken present but no expiresAt -> cannot prove expiry -> OK (ADR §P1.5).
_fake_bin(monkeypatch, tmp_path)
creds = _write_creds(tmp_path, expires_ms=None)
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
ok, _ = preflight.check(force=True)
assert ok is True
# ---------------------------------------------------------------------------
# TC-04 / AC-1: broken / unreadable credentials JSON -> FAIL (no exception)
# ---------------------------------------------------------------------------
def test_broken_json_fails_without_raising(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
creds = _write_creds(tmp_path, raw="{ this is not valid json ")
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
ok, reason = preflight.check(force=True) # must not raise
assert ok is False
assert "logged in" in reason.lower() or "unreadable" in reason.lower()
def test_no_oauth_block_fails(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
creds = _write_creds(tmp_path, oauth=False)
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
ok, reason = preflight.check(force=True)
assert ok is False
assert "oauth" in reason.lower() or "logged in" in reason.lower()
# ---------------------------------------------------------------------------
# TC-05 / AC-5: token-free — no network call in the auth path
# ---------------------------------------------------------------------------
def test_auth_check_makes_no_network_call(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
future = (int(__import__("time").time()) + 3600) * 1000
creds = _write_creds(tmp_path, expires_ms=future)
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
def _no_net(*a, **k):
raise AssertionError("token-free auth check must not open a socket")
monkeypatch.setattr(socket, "socket", _no_net)
ok, _ = preflight.check(force=True)
assert ok is True
# ---------------------------------------------------------------------------
# TC-06 / AC-6: auth result cached within preflight_cache_ttl
# ---------------------------------------------------------------------------
def test_auth_result_cached_within_ttl(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
monkeypatch.setattr(preflight.settings, "preflight_cache_ttl", 999)
calls = {"n": 0}
real = preflight._check_auth
future = (int(__import__("time").time()) + 3600) * 1000
creds = _write_creds(tmp_path, expires_ms=future)
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
def counting():
calls["n"] += 1
return real()
monkeypatch.setattr(preflight, "_check_auth", counting)
preflight.reset_cache()
preflight.check() # miss -> reads creds
preflight.check() # cached -> no re-read
preflight.check()
assert calls["n"] == 1
# ---------------------------------------------------------------------------
# TC-07 / TR-1.3: credentials path resolves from AGENT_HOME, not process env
# ---------------------------------------------------------------------------
def test_credentials_path_follows_agent_home(monkeypatch, tmp_path):
agent_home = tmp_path / "agent_home"
agent_home.mkdir()
monkeypatch.setattr(AgentLauncher, "AGENT_HOME", str(agent_home))
monkeypatch.setattr(preflight.settings, "claude_credentials_path", "")
# The orchestrator process HOME points somewhere else entirely.
monkeypatch.setenv("HOME", str(tmp_path / "orchestrator_home"))
resolved = preflight._credentials_path()
assert resolved == str(agent_home / ".claude" / ".credentials.json")
assert str(tmp_path / "orchestrator_home") not in resolved
def test_explicit_credentials_path_wins(monkeypatch, tmp_path):
monkeypatch.setattr(preflight.settings, "claude_credentials_path",
str(tmp_path / "explicit.json"))
assert preflight._credentials_path() == str(tmp_path / "explicit.json")
# ---------------------------------------------------------------------------
# TC-08 / AC-4: auth-fail blocks the queue-worker claim
# ---------------------------------------------------------------------------
def test_worker_does_not_claim_when_auth_fails(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
monkeypatch.setattr(preflight, "_credentials_path",
lambda: str(tmp_path / "missing.json")) # not logged in
called = {"launch": False}
monkeypatch.setattr("src.queue_worker.launcher.launch_job",
lambda job: called.__setitem__("launch", True))
jid = enqueue_job("analyst", "r")
w = QueueWorker(max_concurrency=1, poll_interval=0.01)
w._drain_once()
assert called["launch"] is False
assert get_job(jid)["status"] == "queued"
assert count_running_jobs() == 0
assert w.last_preflight_ok is False
assert "logged in" in w.last_preflight_reason.lower() \
or "credentials" in w.last_preflight_reason.lower()
# ---------------------------------------------------------------------------
# Toggle off: preflight_check_auth=False keeps the old version-only behaviour
# ---------------------------------------------------------------------------
def test_auth_toggle_off_skips_check(monkeypatch, tmp_path):
_fake_bin(monkeypatch, tmp_path)
monkeypatch.setattr(preflight.settings, "preflight_check_auth", False)
monkeypatch.setattr(preflight, "_credentials_path",
lambda: str(tmp_path / "missing.json"))
ok, _ = preflight.check(force=True)
assert ok is True # auth not consulted -> version-only pass
# ---------------------------------------------------------------------------
# is_auth_failure_text: post-factum marker detection (P1b)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text", [
"Error: Not logged in. Please run /login",
"401 Unauthorized",
"invalid api key provided",
])
def test_is_auth_failure_text_positive(text):
assert preflight.is_auth_failure_text(text) is True
@pytest.mark.parametrize("text", ["", "429 rate limit", "Traceback ValueError"])
def test_is_auth_failure_text_negative(text):
assert preflight.is_auth_failure_text(text) is False

53
tests/test_qg_checks.py Normal file
View File

@@ -0,0 +1,53 @@
"""ORCH-036 TC-15: the deploy-verdict parse contract is unchanged (AC-10).
``_parse_deploy_status`` reads ONLY the machine-readable ``deploy_status:`` YAML
frontmatter (never prose). ORCH-036 produces the verdict differently (a
deterministic finalizer instead of an LLM), but the parse contract that the gate
relies on must remain bit-identical:
SUCCESS -> (True, ...), FAILED -> (False, ...), no/!frontmatter -> (False, ...).
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src.qg.checks import _parse_deploy_status # noqa: E402
from src.self_deploy import build_deploy_log # noqa: E402
def test_tc15_success_frontmatter_passes():
ok, reason = _parse_deploy_status("---\ndeploy_status: SUCCESS\n---\n\nbody")
assert ok is True
assert "SUCCESS" in reason
def test_tc15_failed_frontmatter_fails():
ok, reason = _parse_deploy_status("---\ndeploy_status: FAILED\n---\n\nbody")
assert ok is False
assert "FAILED" in reason
def test_tc15_no_frontmatter_fails():
ok, _ = _parse_deploy_status("just prose, deploy_status: SUCCESS in text but no frontmatter")
assert ok is False
def test_tc15_missing_field_fails():
ok, _ = _parse_deploy_status("---\nother_field: SUCCESS\n---\n")
assert ok is False
def test_tc15_prose_success_word_does_not_pass():
"""Defensive: the word SUCCESS in prose must NOT satisfy the gate."""
ok, _ = _parse_deploy_status("# Deploy\n\nDeploy was a SUCCESS, hooray!\n")
assert ok is False
def test_tc15_finalizer_log_roundtrips_through_parser():
"""The finalizer's rendered log must be readable by the EXISTING parser —
SUCCESS passes, FAILED fails — proving the producer/consumer contract holds."""
ok_s, _ = _parse_deploy_status(build_deploy_log("ORCH-036", 0, "SUCCESS"))
ok_f, _ = _parse_deploy_status(build_deploy_log("ORCH-036", 2, "FAILED"))
assert ok_s is True
assert ok_f is False

Some files were not shown because too many files have changed in this diff Show More