Compare commits

..

54 Commits

Author SHA1 Message Date
2ca06b51b4 tester(ET): auto-commit from tester run_id=367
All checks were successful
CI / test (push) Successful in 22s
CI / test (pull_request) Successful in 21s
2026-06-08 10:30:57 +00:00
e45f4df285 reviewer(ET): auto-commit from reviewer run_id=366
All checks were successful
CI / test (push) Successful in 22s
CI / test (pull_request) Successful in 24s
2026-06-08 10:29:27 +00:00
7a88f390a3 docs(tracker): update CHANGELOG, CLAUDE.md, .env.example for ORCH-067
All checks were successful
CI / test (push) Successful in 22s
CI / test (pull_request) Successful in 21s
Закрывает P0/P1 ревью (attempt 2/3): документация = golden source.
- CHANGELOG.md: запись ORCH-067 в [Unreleased] (bump-дефолт, статус-строка
  карточки по модели ORCH-066, кликабельный номер задачи, новые флаги).
- CLAUDE.md: раздел «Нотификации / Telegram live-tracker» (ТЗ §5).
- .env.example: ORCH_TRACKER_MODE=bump (синхрон с новым дефолтом) +
  ORCH_TRACKER_LIVE_STATUS / _TTL_S / _TIMEOUT_S.

Refs: ORCH-067

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:25:58 +00:00
6ded8d3546 reviewer(ET): auto-commit from reviewer run_id=364
All checks were successful
CI / test (push) Successful in 21s
CI / test (pull_request) Successful in 21s
2026-06-08 10:23:45 +00:00
9e1fe82ef0 developer(ET): auto-commit from developer run_id=363
All checks were successful
CI / test (push) Successful in 23s
CI / test (pull_request) Successful in 23s
2026-06-08 10:19:42 +00:00
2087475d0d architect(ET): auto-commit from architect run_id=362
All checks were successful
CI / test (push) Successful in 21s
2026-06-08 09:58:58 +00:00
191f78bb86 analyst(ET): auto-commit from analyst run_id=361
All checks were successful
CI / test (push) Successful in 21s
2026-06-08 09:51:46 +00:00
2c27e4d95a docs: init ORCH-067 business request
All checks were successful
CI / test (push) Successful in 22s
2026-06-08 12:46:34 +03:00
52ca882e5b Merge pull request 'feat: ORCH-071-crit-bug-merge-main' (#72) from feature/ORCH-071-crit-bug-merge-main into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-08 12:02:47 +03:00
d49e88cf3f tester(ET): auto-commit from tester run_id=359
All checks were successful
CI / test (push) Successful in 23s
CI / test (pull_request) Successful in 24s
2026-06-08 08:45:31 +00:00
e7a5b50f97 reviewer(ET): auto-commit from reviewer run_id=358 2026-06-08 08:45:31 +00:00
034343ec5d docs(changelog): add ORCH-071 merge-verify gate entry
Add CHANGELOG entry for the phantom-merge fix (merge-verify sub-gate,
deterministic merge actor, post-deploy verification, kill-switch).
Addresses P0 blocker from reviewer (attempt 2/3): docs = golden source
per CLAUDE.md §2/§6 and AC-5.

Refs: ORCH-071

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:45:31 +00:00
cc87beb2b4 reviewer(ET): auto-commit from reviewer run_id=356 2026-06-08 08:45:31 +00:00
fb25e9a0cf developer(ET): auto-commit from developer run_id=355 2026-06-08 08:45:31 +00:00
2824fd8543 architect(ET): auto-commit from architect run_id=354 2026-06-08 08:45:31 +00:00
c26a6b637c analyst(ET): auto-commit from analyst run_id=353 2026-06-08 08:45:31 +00:00
dd5fe619d5 docs: init ORCH-071 business request 2026-06-08 08:45:31 +00:00
f6b5671267 docs(ORCH-071): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
Staging check suite passed against orchestrator-staging (8501), exit 0.
All REAL pipeline checks green; sandbox-infra C9a/C9b waived per ORCH-061.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 08:45:01 +00:00
49461238f1 Merge pull request 'restore(main): долить фантомные ORCH-022/059/066/068 (4 потерянных PR)' (#71) from integ/restore-main-2026-06-08 into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-08 09:57:18 +03:00
stream
c90c01b919 fix(tests): integ fixtures — to_analyse always present (066), full status keys, security_gate registered (022)
All checks were successful
CI / test (pull_request) Successful in 20s
2026-06-08 06:41:52 +00:00
stream
2ec6873e33 integ: merge ORCH-068 reconciler livelock fix
# Conflicts:
#	docs/architecture/README.md
#	src/reconciler.py
2026-06-08 06:36:29 +00:00
stream
cac6539698 integ: merge ORCH-066 plane status model
# Conflicts:
#	CHANGELOG.md
#	docs/architecture/README.md
#	src/plane_sync.py
#	src/webhooks/plane.py
2026-06-08 06:34:37 +00:00
stream
af7472df05 integ: merge ORCH-059 confirm-deploy
# Conflicts:
#	CHANGELOG.md
#	docs/architecture/README.md
2026-06-08 06:32:53 +00:00
stream
995ba0af71 integ: merge ORCH-022 security/secret-scanning 2026-06-08 06:32:13 +00:00
772ccab013 docs(history): CRITICAL postmortem — phantom merge (deploy without main-merge), see ORCH-071 2026-06-08 09:20:22 +03:00
post-deploy-monitor
06271b0bfb docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-068
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 18s
2026-06-08 05:49:27 +00:00
101bd1c512 docs(history): lesson — Confirm Deploy dead trigger (ORCH-066 regression, see ORCH-070) 2026-06-08 08:38:30 +03:00
deploy-finalizer
aa4161fc78 deploy(ORCH-036): finalize SUCCESS for ORCH-068
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 20s
2026-06-08 05:34:23 +00:00
6bbd530caa tester(ET): auto-commit from tester run_id=351
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 21s
2026-06-08 05:18:46 +00:00
4b03f213f7 reviewer(ET): auto-commit from reviewer run_id=349 2026-06-08 05:18:46 +00:00
1d72c44587 fix(reconciler): stop F-2 livelock spam on synced terminal tasks + cache TTL
Reconciler F-2 spammed Telegram "<wi> разблокирована" every ~120s for a
fully-synchronized Done task (incident ET-002, 191+ msgs/night) after the
ORCH-066 Plane status model merge. Two stacked defects (defense in depth):

- D1 (selection): actionable states were told apart by bare UUID, so a Done
  issue aliased onto the approved UUID entered the approved branch. Now
  terminal states are excluded by Plane state GROUP (completed/cancelled),
  a project-independent discriminator robust to UUID aliasing; per-issue
  check with a logical-key fallback when the group is unavailable.
  get_project_states caches {uuid -> group} from the same /states/ fetch;
  new sibling accessor get_project_state_groups.
- D2 (notification): _note_unblock fired unconditionally after _dispatch.
  Now it only fires on a confirmed state change (stage before/after _dispatch;
  task-appears for the start case) — handlers' contracts untouched.
- TR-3: in-memory dedup guard {issue_id -> last unblocked state} as a backstop.
- TR-4: _STATES_CACHE lived for the whole process lifetime, so a new Plane
  status was invisible without a restart. Added TTL ORCH_PLANE_STATES_TTL_S
  (default 300s; 0 = previous lifetime cache) reusing reload_project_states();
  a failed refresh serves the stale-but-correct set, not enduro defaults.

STAGE_TRANSITIONS / QG_CHECKS / DB schema / handle_* contracts / F-1 / F-3
unchanged; never-raise preserved; self-hosting tick never restarts prod.
Observability: skipped_terminal_total / deduped_total in /queue reconcile block.

Tests: tests/test_reconciler_plane.py (TC-01..TC-10),
tests/test_plane_states_cache.py (TC-11/TC-12).

Refs: ORCH-068

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 05:18:46 +00:00
0605309602 architect(ET): auto-commit from architect run_id=347 2026-06-08 05:18:46 +00:00
044894cbe9 analyst(ET): auto-commit from analyst run_id=346 2026-06-08 05:18:46 +00:00
cb11137a77 docs: init ORCH-068 business request 2026-06-08 05:18:46 +00:00
48b54051e5 docs(ORCH-068): add staging gate log (staging_status: SUCCESS) 2026-06-08 05:18:24 +00:00
post-deploy-monitor
72d662ae88 docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-066
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 19s
2026-06-07 22:33:36 +00:00
deploy-finalizer
348cf8c164 deploy(ORCH-036): finalize SUCCESS for ORCH-066
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 19s
2026-06-07 22:18:32 +00:00
bc2347abd3 tester(ET): auto-commit from tester run_id=343
All checks were successful
CI / test (push) Successful in 21s
CI / test (pull_request) Successful in 18s
2026-06-07 22:02:45 +00:00
62c1fe3461 reviewer(ET): auto-commit from reviewer run_id=342 2026-06-07 22:02:45 +00:00
0dfddf93f0 feat(plane): осмысленная статусная модель Plane (слой B — индикация)
Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя
инвариант «статус — индикация, а не управление». Меняется только слой B
(отображение: src/plane_sync.py + точки выставления статуса в
stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий
src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21).

- 6 новых логических ключей статуса (to_analyse, analysis, code_review,
  awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер
  set_issue_stage_state.
- Project-relative alias-fallback (BR-12): новый ключ деградирует на
  базовый UUID того же проекта → нулевая регрессия для enduro-trails.
- Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying;
  terminal-sync для self-hosting → Monitoring after Deploy, для прочих →
  терминальный Done.
- Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked
  (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5).
- Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает
  новые активные ожидания без расширения skip-set на алиасах.
- never-raise контракт сеттеров и резолвера состояний сохранён.
- Раскатка — созданием статусов в Plane оператором, без kill-switch.

Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков),
check_deploy_status, exit-код-контракт хука, merge-gate, схема БД.

ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md
Тесты: test_plane_status_model, test_plane_to_analyse_resume,
test_plane_status_failclosed + TC в существующих наборах. 774 passed.

Refs: ORCH-066

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:02:45 +00:00
22d3b77426 architect(ET): auto-commit from architect run_id=340 2026-06-07 22:02:45 +00:00
4a06537afd analyst(ET): auto-commit from analyst run_id=339 2026-06-07 22:02:45 +00:00
b6c0e11e4d docs: init ORCH-066 business request 2026-06-07 22:02:45 +00:00
3fb3d15cb4 docs(ORCH-066): add staging gate log (staging_status: SUCCESS)
Some checks failed
CI / test (push) Has been cancelled
Staging check suite passed (8/10, exit 0): all REAL checks green;
C9a/C9b waived as known sandbox-infra (ORCH-061).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:02:33 +00:00
post-deploy-monitor
0cbb7ef0bb docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-022
All checks were successful
CI / test (push) Successful in 18s
CI / test (pull_request) Successful in 18s
2026-06-07 19:24:29 +00:00
deploy-finalizer
e07ee9e574 deploy(ORCH-036): finalize SUCCESS for ORCH-022
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 17s
2026-06-07 18:42:29 +00:00
8cdb9f194a tester(ET): auto-commit from tester run_id=331
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 19s
2026-06-07 18:04:50 +00:00
cb3bdd9c7a reviewer(ET): auto-commit from reviewer run_id=330 2026-06-07 18:04:50 +00:00
Dev
04233cb3c8 test(ORCH-022): isolate TC-17 worktree under tmp_path (fix CI PermissionError on /repos/_wt)
TC-17 seeded 17-security-report.md via get_worktree_path() which resolves to
settings.worktrees_dir (default /repos/_wt) -> the test wrote into the real shared
host worktree path. In CI that dir is owned by another user -> PermissionError.

Monkeypatch git_worktree.settings.worktrees_dir to tmp_path/_wt (same pattern as
test_git_worktree.py / test_merge_gate.py). Prod logic untouched.
2026-06-07 18:04:50 +00:00
stream
85ecf50926 ci: re-run after gitea restart (ORCH-022 flaky CI) 2026-06-07 18:04:50 +00:00
30b6187c73 feat(security): security-gate (gitleaks secret-scan + pip-audit) before merge
Add a deterministic (no-LLM) security sub-gate on the deploy-staging -> deploy
edge, run FIRST (before merge-gate ORCH-043 and image-freshness ORCH-058) so it
fails cheaply before any expensive rebase/rebuild, and scans origin/main..HEAD
before rebase so a task is never blamed for a CVE introduced by an updated main.

Why: the autonomous pipeline merged branches into main with no check for a leaked
secret or a vulnerable dependency. For the self-hosting orchestrator (one shared
prod instance serving every project from a shared DB) a single leak/CVE landed in
the prod of all projects (CLAUDE.md self-hosting, section 8).

- New leaf src/security_gate.py (never-raise): gitleaks (offline, fail-closed on
  tool error => secrets guarantee is unconditional) + pip-audit (best-effort;
  unreachable CVE feed degrades fail-open + loud warning by default, strict via
  security_dep_audit_fail_closed). Verdict lives ONLY in 17-security-report.md
  YAML frontmatter (write -> read-back single source of truth); FAIL is
  authoritative; missing/broken frontmatter => fail-closed.
- check_security_gate thin wrapper registered in QG_CHECKS (lazy import, no cycle).
- _handle_security_gate wired FIRST in advance_stage deploy-staging block: FAIL ->
  rollback to development + developer-retry (cap MAX_DEVELOPER_RETRIES); task_desc
  carries verbatim findings (ORCH-046 pattern). No merge-lease release (runs before
  lease acquire). Self-hosting safe: only reads/scans/writes, never deploys.
- Conditional rollout (security_gate_enabled + security_gate_repos; empty scope ->
  self-hosting only). 6 new ORCH_SECURITY_* settings.
- Infra: pinned gitleaks Go binary in Dockerfile (+curl/ca-certificates), pip-audit
  in requirements.txt, versioned .gitleaks.toml at repo root.
- STAGE_TRANSITIONS and DB schema unchanged.

Docs: docs/architecture/README.md (marked realized), CLAUDE.md (artifact 17),
CHANGELOG.md. Tests: test_security_gate.py, test_qg_security.py,
test_stage_engine_security_gate.py + updated registry/edge snapshots.

Refs: ORCH-022

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:04:50 +00:00
44db94e462 architect(ET): auto-commit from architect run_id=327 2026-06-07 18:04:50 +00:00
4f24f96169 analyst(ET): auto-commit from analyst run_id=326 2026-06-07 18:04:50 +00:00
2d20da295e docs: init ORCH-022 business request 2026-06-07 18:04:50 +00:00
121 changed files with 10957 additions and 123 deletions

View File

@@ -12,11 +12,25 @@ 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-042/ORCH-067: live-tracker mode. bump (DEFAULT since ORCH-067) -> on every
# update the old card is deleted and a fresh one is sent silently to the BOTTOM of
# the chat (deleteMessage + sendMessage + repoint), so the current status is always
# the last message in an active chat. edit -> the task card is edited in place
# (editMessageText). One card per task in both modes. Any value other than "bump"
# (incl. empty/garbage) -> edit.
ORCH_TRACKER_MODE=bump
# ORCH-067: best-effort live-overlay for the card status line. The offline core
# (stage -> Plane status, In Review from the brd-clock) always works without network;
# the overlay only fills in branches indistinguishable offline (Needs Input / Blocked /
# Rejected / Cancelled / Deploying / Monitoring after Deploy) by reading the LIVE Plane
# status with a short timeout + per-issue TTL cache. It NEVER blocks the pipeline and
# NEVER raises.
# LIVE_STATUS -> kill-switch (false -> offline core only).
# LIVE_STATUS_TTL_S -> TTL (seconds) of the per-issue live-uuid cache (hot-path guard).
# LIVE_STATUS_TIMEOUT_S -> timeout (seconds) of a single live-GET on the render path.
ORCH_TRACKER_LIVE_STATUS=true
ORCH_TRACKER_LIVE_STATUS_TTL_S=60
ORCH_TRACKER_LIVE_STATUS_TIMEOUT_S=3
# 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
@@ -117,6 +131,15 @@ ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
# ORCH-068: TTL (seconds) for the per-project Plane states cache (plane_sync
# _STATES_CACHE). Historically the cache lived for the whole process lifetime,
# so a status added to Plane after start was invisible until a restart
# ("stale set -> no pipeline action"). With a TTL the entry self-heals by
# re-fetching /states/ once it expires (reuses reload_project_states()).
# >0 -> re-fetch after this many seconds (default 300 = 5 min);
# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat).
ORCH_PLANE_STATES_TTL_S=300
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread
# (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps
# zombie 'running' jobs whose monitor/process died before writing the terminal status
@@ -146,6 +169,27 @@ ORCH_REAPER_MAX_RUNNING_S=3600
ORCH_REAPER_FINALIZE_GRACE_S=300
ORCH_LEASE_RECLAIM_ENABLED=true
# ORCH-022: security-gate (secret-scanning + dependency audit) on the
# deploy-staging -> deploy edge, run FIRST among the edge sub-gates. Deterministic
# (no LLM): gitleaks (offline secret-scan, pinned Go binary in the image) + pip-audit
# (OSV/PyPI CVE audit). Verdict in the versioned 17-security-report.md frontmatter;
# FAIL -> rollback to development + developer-retry (cap 3). See ADR-001.
# GATE_ENABLED -> global kill-switch; false -> pipeline 1:1 as before ORCH-022.
# GATE_REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting.
# DEP_BLOCK_SEVERITY -> CVE severity that BLOCKS (CRITICAL>HIGH>MEDIUM>LOW); below /
# UNKNOWN -> warning only (anti-loop).
# SCAN_TIMEOUT_S -> per external scanner call timeout.
# DEP_AUDIT_FAIL_CLOSED -> strict mode: unreachable CVE feed -> FAIL instead of the
# default fail-open + warning (anti-loop). Default false.
# SECRETS_BLOCK -> a found secret blocks (always true by default; the offline
# secrets guarantee is unconditional).
ORCH_SECURITY_GATE_ENABLED=true
ORCH_SECURITY_GATE_REPOS=
ORCH_SECURITY_DEP_BLOCK_SEVERITY=HIGH
ORCH_SECURITY_SCAN_TIMEOUT_S=300
ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED=false
ORCH_SECURITY_SECRETS_BLOCK=true
# ORCH-021: post-deploy production monitoring + degradation reaction. After the
# terminal deploy->done transition for an applicable repo, a reserved-agent job
# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a

38
.gitleaks.toml Normal file
View File

@@ -0,0 +1,38 @@
# gitleaks config — ORCH-022 security-gate (secret-scanning).
#
# Versioned in the repo root (07-infra I-4 / BR-13): rules + an allowlist of
# known-safe matches are reviewed as code. The security-gate (src/security_gate.py)
# passes this file via `--config` when present. gitleaks runs OFFLINE (local rules)
# so the "a secret always blocks" guarantee (BR-2) never depends on the network.
#
# Strategy: extend the built-in ruleset (broad coverage, maintained upstream) and
# only ADD a narrow allowlist for placeholders / fixtures that are intentionally
# fake (e.g. .env.example dummy values, test fixtures). Keep the allowlist tight —
# an over-broad allowlist silently re-opens the leak it was meant to bless.
title = "orchestrator gitleaks config"
[extend]
# Start from gitleaks' maintained default ruleset.
useDefault = true
[allowlist]
description = "Known-safe, intentionally non-secret matches (placeholders + fixtures)."
# Files that legitimately contain placeholder/dummy secret-shaped values:
# * .env.example — the committed canon of env vars with DUMMY values (CLAUDE.md §8;
# real secrets live only in the host .env / .env.staging, never in git).
# * tests/ — fixtures may embed fake tokens to exercise the scanner itself (TC-03).
# * .gitleaks.toml — this file (avoid self-matching example patterns below).
paths = [
'''(^|/)\.env\.example$''',
'''(^|/)tests/''',
'''(^|/)\.gitleaks\.toml$''',
]
# Generic placeholder tokens used in docs / examples that are NOT real secrets.
regexes = [
'''(?i)(your[-_]?(token|key|secret|password)[-_]?here)''',
'''(?i)(changeme|dummy|example|placeholder|xxxxx+)''',
'''(?i)<[a-z0-9_-]+>''',
]

File diff suppressed because one or more lines are too long

View File

@@ -38,16 +38,35 @@ created → analysis → architecture → development → review → testing →
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
```
## Статусная модель Plane (ORCH-066) — индикация ≠ управление
Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`.
## Нотификации / Telegram live-tracker (ORCH-042/066/067)
Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки:
- **Дефолт `tracker_mode``bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`).
`bump` на каждом обновлении удаляет старую карточку и шлёт свежую вниз чата (тихо), `edit`
редактирует на месте. Инвариант «одна карточка на задачу» — в обоих режимах.
- **Статус-строка карточки** (`📍 <status_label>`) показывает текущий Plane-статус по модели
ORCH-066 (`plane_status_label`). Оффлайн-ядро (`stage → статус`, In Review из brd-clock)
работает всегда без сети; best-effort live-overlay (kill-switch `tracker_live_status`,
TTL-кэш, короткий таймаут) лишь дорисовывает ветки, неотличимые offline (Needs Input /
Blocked / Rejected / Cancelled / Deploying / Monitoring) и **никогда не блокирует конвейер**.
- **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех
уведомлениях (`notify_*`, alert'ы стадий) рендерится как `<a href=…>` на issue в Plane;
fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает.
- Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification`
(карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются.
## Конвенции
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
- Work items: `docs/work-items/<plane-id>/`
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_status:`), никогда проза
## Артефакты задачи (`docs/work-items/<plane-id>/`)
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021).
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022).
## Правила для агентов
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.

View File

@@ -8,9 +8,28 @@ FROM python:3.12-slim
ARG GIT_SHA=""
LABEL org.opencontainers.image.revision=$GIT_SHA
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/*
RUN apt-get update -qq && apt-get install -y -qq openssh-client git curl ca-certificates && rm -rf /var/lib/apt/lists/*
# git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it.
RUN git config --system --add safe.directory '*'
# ORCH-022: pinned gitleaks static Go binary for the offline secret-scan sub-gate
# (07-infra I-1). Baked into the image (NOT a pip package): the gate runs INSIDE the
# orchestrator container over a per-task worktree. Pinned release => deterministic
# rules; gitleaks needs no network so the "a secret always blocks" guarantee (BR-2)
# is independent of internet access. Multi-arch aware (amd64/arm64).
ARG GITLEAKS_VERSION=8.18.4
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) gl_arch="x64" ;; \
arm64) gl_arch="arm64" ;; \
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
esac; \
curl -fsSL -o /tmp/gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz"; \
tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks; \
chmod +x /usr/local/bin/gitleaks; \
rm -f /tmp/gitleaks.tar.gz; \
gitleaks version
# ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base
# image has no passwd entry for uid 1000 -> ssh/whoami fail with
# "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh

View File

@@ -13,8 +13,9 @@
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). Все алерты, упоминающие `work_item_id`, делают номер кликабельным. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
## Конвейер и Quality Gates
@@ -36,7 +37,7 @@ 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, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058).
**Реестр 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), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022).
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
@@ -121,6 +122,44 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
Детально — `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md`
(уточняет/триггер Фазы B относительно adr-0007).
#### Merge-в-main + пост-деплой верификация как условие `done` (ORCH-071 — фикс фантомного merge)
**Фантомный merge** (CRITICAL, постмортем `docs/history/LESSONS_2026-06-08_phantom-merge.md`):
на self-hosting пути `deploy` агент `deployer` НЕ запускается, а фактический merge PR в `main`
исторически делал ТОЛЬКО он → детерминированный путь
(`_handle_self_deploy_phase_b → initiate_deploy → run_deploy_finalizer`) **не содержал шага
merge-в-main вообще**. Detached host-деплой лишь retag'ал образ + рестартил 8500; `done`
достигался по `deploy_status: SUCCESS` без верификации `main`. Зелёный деплой (образ из рабочей
ветки) маскировал отсутствие merge → следующая задача срезала ветку от устаревшего `main` и
теряла код предшественника (накопительно потеряны ORCH-022/059/066/068). ORCH-071 вводит
**детерминированный merge-актор + пост-merge верификацию** как **под-гейт ребра `deploy → done`**
(симметрично edge-под-гейтам `deploy-staging → deploy`), только для self-hosting:
- **Врезка `_handle_merge_verify` в `advance_stage`** (`current_stage=="deploy"` и
`next_stage=="done"`, ПОСЛЕ зелёного `check_deploy_status`, ДО `update_task_stage`). Гейтит
**ВСЕ** пути к `done` единообразно (`run_deploy_finalizer` Phase C, reconciler F-1, job-reaper —
все идут через `advance_stage`), закрывая дыру обхода merge.
- **Merge в Phase C (после рестарта), НЕ в Phase B** — finalizer restart-surviving (claim воркером
нового контейнера, re-drive reaper'ом), merge физически строго ПОСЛЕ рестарта прода → рестарт его
не убивает (G3 «шаг, переживающий рестарт»; постмортем-урок №3).
- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (no-op повтор, ORCH-065) → иначе
Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Никогда push/force-push в `main`.
- **Верификатор `merge_gate.verify_merged_to_main`** — `PR.merged==true` ИЛИ
`git merge-base --is-ancestor <validated_sha> origin/main` (`validated_revision` — тот же якорь,
что у ORCH-058). never-raise → `False`.
- **Не подтверждено → alert «deploy succeeded but not merged» (Telegram+Plane) + HOLD**
(`set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на `development` — not-merged есть
инфра-дефект, реакция ALERT-only как ORCH-021 self-hosting). Подтверждено → штатный `deploy →
done` + `merged_to_main: true` во frontmatter `14-deploy-log.md` (`deploy_status:` нетронут).
- **Условность как ORCH-35/43/58:** `merge_verify_enabled` (kill-switch, дефолт `true`) +
`merge_verify_repos` (пусто → только self-hosting); non-self — no-op, merge остаётся за `deployer`.
never-raise; идемпотентность (`pr_already_merged`, INV-5); ручной approve сохранён (`Confirm Deploy`).
- **Инварианты:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, реестр
`QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG), схема БД,
БАГ-8, terminal-sync, merge-gate, image-freshness, exit-коды хука — **без изменений**.
Диагностика фантома — runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки постмортема).
Подробнее: [adr-0013](adr/adr-0013-merge-verify-gate.md), детально —
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`.
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
@@ -184,6 +223,38 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — реализовано)
Автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/
приватный ключ) и уязвимую зависимость (CVE); для self-hosting один секрет/CVE через одну
задачу уезжал в общий прод всех проектов (CLAUDE.md §8). ORCH-022 вводит детерминированный
(без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, рядом с merge-gate
(ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди edge-под-гейтов
(ДО merge-gate). Паттерн соседей: leaf `src/security_gate.py` (never-raise) + тонкая обёртка
`check_security_gate` в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`.
`STAGE_TRANSITIONS` и схема БД — **без изменений**.
- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне
аллоулиста `.gitleaks.toml` → вклад в FAIL. Offline → гарантия «секрет всегда блокирует»
не зависит от сети (безусловна).
- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity`
(дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open +
громкий warning** (анти-петля ORCH-061; флаг `security_dep_audit_fail_closed` для строгого
режима). best-effort при доступности фида.
- **ПЕРВЫМ, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки ДО rebase
не «обвиняет» задачу в CVE из обновившегося `main`; до захвата merge-lease → при FAIL lease
освобождать не нужно.
- **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/
`deps_blocking`/`deps_warning`/`deps_audit_degraded`); вердикт читается ТОЛЬКО из
frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает: единый
источник истины), negative-токен авторитетен, битый/нет → fail-closed.
- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3,
затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046).
- **Условность как ORCH-35/43/58:** `security_gate_enabled` + `security_gate_repos` (пусто →
только self-hosting); never-raise; таймаут `security_scan_timeout_s`; гейт не деплоит/не
рестартит прод. v1 — Python-only; SAST/мульти-стек — follow-up (BR-14).
Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально —
`docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
@@ -203,11 +274,21 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
retry-count проверяется первым (дёшево, локальный SQL).
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
**ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane
(`state.group ∈ {completed, cancelled}`, fallback — логические ключи
`done`/`cancelled`) исключаются из actionable-выборки per-issue — проектно-независимо,
устойчиво к UUID-алиасингу после переименований статусов (ORCH-066); (2) `_note_unblock`
(лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО при **подтверждённом state change**
(сравнение стадии задачи до/после `_dispatch`; no-op dispatch → тишина), плюс in-memory
дедуп по `issue_id→state`. Восстанавливает инвариант silence-when-in-sync (AC-9/AC-10).
Детали — `docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`.
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
development-задаче repo; неоднозначность → не резолвим).
- **F-4 observability:** при разблокировке — лог-строка `reconciler: <wi> <stage>
разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок
состояния в `GET /queue` (блок `reconcile`).
состояния в `GET /queue` (блок `reconcile`). **ORCH-068** добавляет в снимок
счётчики `skipped_terminal_total` (исключённые терминалы) и `deduped_total`
(подавленные повторные нотификации).
Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в
`main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед**
@@ -278,6 +359,46 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
### Осмысленная статусная модель Plane (ORCH-066 — реализовано)
Plane-доска была семантически перегружена: `In Progress` означал «человек запускает
конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно.
ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B**
(Plane-индикация: `src/plane_sync.py` + точки простановки в `src/stage_engine.py`/
`src/webhooks/plane.py`/`src/reconciler.py`) и **не трогая слой A** (`STAGE_TRANSITIONS`,
инвариант). Статус — индикация, не управление (вердикты по-прежнему из YAML-frontmatter):
```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
`[...]` = человеческий вход-триггер; остальное ставит орк.
- **6 новых логических ключей** (`to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/
`deploying`/`monitoring`) в `_PLANE_NAME_TO_KEY` (резолв по имени) + `_DEFAULT_STATES`.
`To Analyse` заменяет `In Progress` как вход-триггер (старт + resume аналитика из Needs
Input; fork «старт vs resume» по `get_task_by_plane_id`+`has_active_job_for_task` —
сохранён). Стадии: analysis→`Analysis`, review→`Code-Review` (`_STAGE_TO_STATE_KEY`).
- **Self-deploy фазы:** Phase A → `Awaiting Deploy` (разгружает `In Review`), Phase B →
`Deploying`, Phase C/terminal-sync (self) → `Monitoring after Deploy` (НЕ `Done` сразу);
post-deploy monitor (ORCH-021): HEALTHY-окно → `Done`, DEGRADED → `Blocked` (тик
по-прежнему НИКОГДА не рестартит прод — ALERT_ONLY). Не-self репо: `deploy → Done` как
сейчас (terminal-sync разводится по `post_deploy.post_deploy_applies`).
- **Fail-closed (project-relative alias-fallback):** отсутствующий новый статус в проекте
деградирует на **собственный базовый UUID того же проекта** (`to_analyse/analysis→in_progress`,
`code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`,
`monitoring→done`) — индикация откатывается к текущей, конвейер не ломается, PATCH валиден
даже при частичной конфигурации. Enduro (статусы не создаются) → строго прежнее поведение.
Усиленный паттерн ORCH-059 AC-7.
- **Reconciler:** F-2 триггер `in_progress`→`to_analyse`; Guard 2 skip-set расширен
активными ожиданиями (`awaiting_deploy`/`deploying`/`monitoring`) с **вычитанием базовых
рабочих статусов** — на enduro (алиасы схлопнуты) нулевой регресс, на orchestrator skip
реальных ожиданий (BR-13).
- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, exit-коды хука,
merge-gate, `Confirm Deploy`, механизм `Needs Input` (analyst-only), схема БД — без
изменений. Без нового kill-switch (раскат гейтится созданием Plane-статусов оператором).
Инфра-предусловие — `docs/work-items/ORCH-066/07-infra-requirements.md`.
Подробнее: `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`.
## Откаты
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
@@ -335,4 +456,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).*
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).*
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).*
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-068 (D1 терминал-гард по группе `_is_terminal_state` + `get_project_state_groups` в src/plane_sync.py; D2 сравнение стадии до/после `_dispatch` + дедуп-словарь в src/reconciler.py; TTL-запись `_STATES_CACHE` + флаг `plane_states_ttl_s` в src/config.py; счётчики `skipped_terminal_total`/`deduped_total` в `/queue`; обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`get_project_state_groups`/`_STATES_CACHE`).*

View File

@@ -69,6 +69,15 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти
задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise,
тишина при пропуске).
- **ORCH-068** (`docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`):
фикс livelock F-2 (спам `_note_unblock` по синхронизированной done-задаче после
ORCH-066). F-2 исключает терминалы по **группе состояния** (`completed`/`cancelled`,
fallback — ключи `done`/`cancelled`) проектно-независимо; `_note_unblock` — только при
подтверждённом state change (сравнение стадии до/после `_dispatch`) + in-memory дедуп;
`_STATES_CACHE` получает TTL (`ORCH_PLANE_STATES_TTL_S`, дефолт 300с, `0`=lifetime).
Инварианты adr-0007 сохранены (источник истины — Plane; реестры/схема/`handle_*`/F-1/F-3
не меняются; never-raise; kill-switch'и).
## Связи
adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный
гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра

View File

@@ -0,0 +1,63 @@
# adr-0012: Security-гейт — secret-scanning + dependency audit перед мержем
- **Статус:** proposed
- **Дата:** 2026-06-07
- **Задача:** ORCH-022
- **Детальный ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`
## Контекст
Оркестратор автономен: `developer` пишет код без человека-фильтра. Перед слиянием ветки в
`main` нет проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую
зависимость (CVE). Для self-hosting один общий прод-инстанс обслуживает все проекты с общей
БД — секрет/CVE через одну задачу попадает в прод всех (CLAUDE.md §self-hosting, §8). Фактический
мерж PR в `main` делает `deployer` в начале стадии `deploy`.
## Решение
Детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**,
рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди
edge-под-гейтов (ДО merge-gate). `STAGE_TRANSITIONS` не меняется; в `QG_CHECKS` добавлен
`check_security_gate`. Паттерн — как у соседей: leaf-модуль `src/security_gate.py`
(never-raise) + тонкая обёртка в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`.
- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне
аллоулиста (`.gitleaks.toml`) → вклад в FAIL. Offline → гарантия «секрет всегда блокирует»
не зависит от сети.
- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity`
(дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open +
громкий warning** (анти-петля; флаг `security_dep_audit_fail_closed` для строгого режима).
- **ПЕРВЫМ на ребре, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки
ДО rebase не «обвиняет» задачу в CVE, притащенной обновившимся `main` (анти-петля
ORCH-061); до захвата merge-lease → при FAIL lease освобождать не нужно.
- **Артефакт `17-security-report.md`** с YAML-frontmatter (`security_status`,
`secrets_found`, `deps_blocking`, `deps_warning`, `deps_audit_degraded`); вердикт читается
ТОЛЬКО из frontmatter (канон), negative-токен авторитетен; битый/нет → fail-closed.
- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3,
затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046).
- **Условность (как ORCH-35/43/58):** `security_gate_enabled` + `security_gate_repos`; пусто
→ реально только self-hosting (`orchestrator`), прочие репо — no-op pass.
- **never-raise**, таймаут `security_scan_timeout_s`, гейт не деплоит/не рестартит прод.
## Альтернативы
- **Вариант R (review-стадия):** diff может разойтись с мержем в `main`; merge-edge — последняя
страховка. Отклонено.
- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо
выражаются статусом коммита; коуплинг с раннером. Отклонено для v1 (точка расширения).
- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как в ORCH-043).
Отклонено.
- **fail-closed dep-audit / аудит после rebase:** ложные откаты → петля. Отклонено.
- **Новая колонка retry в БД:** не нужна (переиспользуем `_developer_retry_count`).
## Последствия
- Класс «тихо влитый секрет/CVE» закрыт: секреты — безусловно (offline), CVE — best-effort при
доступности фида. Самоприменение CLAUDE.md §8 без человека.
- Плата: ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); внешние инструменты
(gitleaks в образе, pip-audit в зависимостях); время скана на каждом прогоне (ограничено
таймаутом); v1 — Python-only (SAST/мульти-стек — follow-up WI).
- Сквозное изменение (новый QG + edge-под-гейт) → `arch:major-change`; прод-деплой ORCH-022 —
строго через staging-гейт (8501), без рестарта прод-контейнера.
## Связи
adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness —
условность/never-raise/fail-closed), adr-0003 (условный гейт / `is_self_hosting_repo`),
adr-0009 (анти-петля ложных FAIL, ORCH-061), ORCH-046 (дословный reason в `task_desc`),
ORCH-9/15 (мульти-стек — будущая зависимость), ORCH-2 (worktree-изоляция).

View File

@@ -0,0 +1,63 @@
# adr-0013: Merge-в-main + пост-деплой верификация как условие `done` (фикс фантомного merge)
- **Статус:** accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-071 (CRITICAL bug)
- **Детальный ADR:** `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`
- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md`
## Контекст
Для self-hosting репо `orchestrator` стадия `deploy` идёт детерминированным путём
(`_handle_self_deploy_phase_b → initiate_deploy → run_deploy_finalizer`), а LLM-агент
`deployer` НЕ запускается. Фактический merge PR в `main` исторически делал **только**
агент `deployer` → на self-hosting пути **нет шага merge-в-main вообще**. Detached
host-деплой лишь retag'ает образ + рестартит 8500; `done` достигается по
`deploy_status: SUCCESS` без верификации `main`. «Зелёный» деплой (образ из рабочей
ветки) маскирует отсутствие merge → следующая задача срезает ветку от устаревшего `main`
и теряет код предшественника. Накопительно потеряны ORCH-022/059/066/068. Вторичный
фактор: Phase B рестартит прод → merge внутри живого процесса гонялся бы с рестартом
(урок №3).
## Решение
Детерминированный **merge-актор + пост-merge верификация** как **под-гейт ребра
`deploy → done`**, врезанный в единственную функцию перехода `advance_stage` (симметрично
edge-под-гейтам security/merge-gate/image-freshness). `STAGE_TRANSITIONS`,
`check_deploy_status`/`_parse_deploy_status`, реестр `QG_CHECKS`, схема БД — **не меняются**.
- **Врезка `_handle_merge_verify` в `advance_stage`** (`current_stage=="deploy"` и
`next_stage=="done"`, ПОСЛЕ зелёного `check_deploy_status`, ДО `update_task_stage`).
Гейтит **ВСЕ** пути к `done` единообразно: `run_deploy_finalizer` (Phase C), reconciler
F-1, job-reaper — все идут через `advance_stage`. Закрывает дыру: reconciler F-1 иначе
протолкнул бы `done` в обход merge.
- **Merge в Phase C (после рестарта), НЕ в Phase B.** Phase C finalizer —
restart-surviving (reserved-job `deploy-finalizer`, claim воркером нового контейнера,
re-drive reaper'ом). Merge физически строго ПОСЛЕ рестарта → рестарт его не убивает
(G3 вторым вариантом — «шаг, переживающий рестарт»).
- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (no-op повтор, ORCH-065) →
иначе Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Никогда push/force-push в
`main`. never-raise.
- **Верификатор `merge_gate.verify_merged_to_main`** — `PR.merged==true` ИЛИ
`git merge-base --is-ancestor <validated_sha> origin/main`. never-raise → `False`
(«не подтверждено»).
- **Не подтверждено → alert «deploy succeeded but not merged» (Telegram+Plane) + HOLD**
(`set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на `development` — not-merged
есть инфра-дефект, реакция ALERT-only как ORCH-021 self-hosting). Подтверждено →
штатный `deploy → done` (терминал-sync / post-deploy monitor как сегодня) +
`merged_to_main: true` во frontmatter `14-deploy-log.md` (наблюдаемость, `deploy_status:`
нетронут).
- **Идемпотентность (INV-5):** `pr_already_merged` перед merge; verify зелёный для
уже-слитого PR; повтор без дубль-merge/ложного отката.
- **Условность (как ORCH-35/43/58):** `merge_verify_enabled` (kill-switch, дефолт `true`) +
`merge_verify_repos` (пусто → только self-hosting). Non-self репо — no-op, merge остаётся
за агентом `deployer`.
## Инварианты
never-raise на verify/merge (ошибка → alert, не падение конвейера); не рестартить/не ронять
прод 8500; ручной approve прод-деплоя сохранён (`Confirm Deploy`, ORCH-059); только PR-merge
API Gitea; restart-safe (sentinel + jobs, без миграции БД).
## Последствия
Невозможно «`done` + прод задеплоен, а PR `open`». Минусы: при недоступной Gitea verify
консервативно `False` → возможен ложный HOLD+alert (снимается повтором; fail-closed для
`done` приоритетен); HOLD требует ручного вмешательства. Диагностика фантома — runbook
`docs/operations/PHANTOM_MERGE_RUNBOOK.md` (G4).

View File

@@ -111,12 +111,12 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
Вместо ~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` нулевая регрессия и безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.
**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`; дефолт переключён `edit → bump` в ORCH-067).** Резолвится в `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`). За один вызов — не более одного нового сообщения. |
| `bump` (дефолт, ORCH-067) | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)``send_telegram(text, disable_notification=True)``set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. Живая карточка всегда «догоняет» переписку. |
| `edit` | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее`edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). |
**`delete_telegram(message_id) -> bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»:
- `ok:true``True`;
@@ -128,6 +128,12 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
**Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются.
**Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация).** Под заголовком карточка несёт строку `📍 <Plane-статус>` по модели ORCH-066. Источник — двухслойный, контракт **never raises**:
- **Оффлайн-ядро** `plane_status_label(task_row)` — чистая функция БЕЗ сети: `stage → статус` (`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, `development→Development`, `review→Code-Review`, `testing→Testing`, `deploy→⏸ Awaiting Deploy`, `done→Done`) + `⏸️ In Review` из brd-часов (`brd_review_started_at` задан, `…_ended_at` пуст). Неизвестная/битая стадия → безопасный дефолт `To Analyse`.
- **Live-overlay** `_live_plane_branch_override` — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (`fetch_issue_state` с коротким `tracker_live_status_timeout_s`, TTL-кэш `tracker_live_status_ttl_s`, kill-switch `tracker_live_status`). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; `⏸️ In Review` (авторитет brd-часов) overlay не консультирует. Анти-false-positive: `deploying/monitoring`, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override.
**Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без `<a>`; динамические части экранируются, `<a>`-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`).
## Database Schema
```sql

View File

@@ -0,0 +1,33 @@
# Lessons Learned — 2026-06-08: статус `Confirm Deploy` не триггерит Phase B (мёртвый триггер)
## Контекст
ORCH-066 ввела новую статусную модель Plane, включая человекочитаемый статус **`Confirm Deploy`** для прод-деплойного approve-gate (self-deploy Phase B). Орк сам выставляет задачу в `Awaiting Deploy` / `Confirm Deploy` через `set_issue_awaiting_deploy()` и т.п.
## Инцидент (2026-06-08, первый реальный прод-self-deploy — ORCH-068)
Слава нажал статус **`Confirm Deploy`** в Plane, ожидая запуск прод-деплоя. Орк ответил `no pipeline action` и НИЧЕГО не запустил. Прод-деплой стартовал только после ручного перевода в **`Approved`**.
## Root cause
Диспетчер статусов `handle_issue_status` (`src/webhooks/plane.py` ~158-166) слушает РОВНО три состояния:
```python
if new_state == proj_states["to_analyse"]: await handle_status_start(...)
elif new_state == proj_states["approved"]: await handle_verdict(..., approved=True)
elif new_state == proj_states["rejected"]: await handle_verdict(..., approved=False)
else: logger.info("... no pipeline action")
```
Phase B (прод-деплой) триггерится в `_try_advance_stage` (`src/stage_engine.py` ~215-224) при `current_stage == "deploy" and finished_agent is None` — то есть ТОЛЬКО когда пришёл вебхук `Approved`. Статус `Confirm Deploy` в эту тройку НЕ входит → ветка `else` → no-op.
**ORCH-066 добавила статус как МЕТКУ (запись), но не подключила обратный путь (чтение/триггер).** Классическая дыра: протестировали, что орк правильно СТАВИТ статус, но не протестировали, что нажатие этого статуса человеком РЕАЛЬНО запускает действие.
## Почему не поймали тестирование/ревью
1. **Не в scope ORCH-068.** ORCH-068 чинит reconciler (BRD §6 N1-N3 явно: не трогать диспетчер статусов / Phase B). Тестер прогнал TC-01..13 — все про reconciler/terminal-статусы. Ревьюер смотрел diff reconciler.py/plane_sync.py. Корректно — это дефект ORCH-066, не 068.
2. **Дыра ORCH-066.** Её тесты, видимо, проверяли запись статусов, а не обратный триггер.
3. **Staging не покрывает прод-путь.** Phase A (staging-деплой) автоматический, ручной `Confirm Deploy` живёт ТОЛЬКО на прод-пути, который на staging не гоняется. Поэтому всплыло лишь на первом реальном прод-деплое.
## Уроки
1. **Тестировать обратный путь статусов, не только запись.** Для каждого статуса, который человек может нажать, нужен тест «нажатие → ожидаемое pipeline-действие». Запись (орк ставит статус) и чтение (орк реагирует на статус) — два разных контракта.
2. **Прод-only пути (ручной Confirm Deploy) нуждаются в явном тесте/чеклисте.** Staging их не ловит by design. Любой approve-gate, доступный человеку, обязан иметь регресс-тест на триггер.
3. **Новый статус = подключить В ОБЕ стороны.** При добавлении статуса в модель — сразу проверить, что диспетчер `handle_issue_status` его слушает (если он actionable), а не только что орк его выставляет.
4. **UX-консистентность:** статус, названный действием («Confirm Deploy»), обязан выполнять это действие. Иначе оператор жмёт интуитивную кнопку, а система молчит → потеря доверия к автономности.
## Фикс
Заведена ORCH-070: подключить `Confirm Deploy` (или его actionable-эквивалент) к триггеру Phase B в `handle_issue_status`, + регресс-тест на обратный путь статусов прод-деплоя. Source-of-truth и существующий `Approved`-путь не ломать (обратная совместимость).

View File

@@ -0,0 +1,47 @@
# Lessons Learned — 2026-06-08: «Фантомный merge» — прод деплоится, но код не сливается в main
## Severity: CRITICAL (потеря целостности main, накопительная потеря кода между задачами)
## Резюме
Self-deploy (Phase B) собирал прод-образ из ВЕТКИ задачи и рапортовал `finalize SUCCESS` + `post-deploy HEALTHY`, но git-merge ветки в `main` НЕ происходил. PR оставался `open`. Следующая задача срезала свою ветку от устаревшего main → теряла код незалитых предшественников. Накопительно потеряны в main: **ORCH-022, ORCH-059, ORCH-066, ORCH-068** (PR#67/68/69/70 — все open, merged=False). Последний реально слитый — ORCH-065 (PR#66).
## Как обнаружено
Симптом: ORCH-067 переведён в `To Analyse`, но конвейер не стартовал (`no pipeline action`). Причина — прод слушал старый триггер `in_progress`, а не `to_analyse` (ORCH-066). При разборе выяснилось: код ORCH-066 не в проде, хотя он «деплоился».
Решающее наблюдение оператора (Слава): «спам ET-002 начался СРАЗУ после деплоя 66 → значит код деплоился». Это вскрыло механизм: код 66 БЫЛ в проде 22:1705:32, потом стёрт деплоем 068 (срезан от старого main без 66).
## Доказательная база (как подтверждали — воспроизводимый метод)
1. **PR-статус (Gitea API):** PR#67(022)/68(059)/69(066)/70(068) = open, merged=False. PR#66(065) = merged=True (последний честный).
2. **md5-сверка файлов прод vs origin/main vs ветка:**
- `src/reconciler.py`, `src/plane_sync.py`: prod md5 == ветка ORCH-068 != main → прод = снимок ветки 068, НЕ main.
- `src/webhooks/plane.py`: prod == main == ветка-068 (ветка 068 этот файл не трогала → видна старая база без to_analyse).
3. **git merge-base:** ветка ORCH-068 срезана от `bb03350` (ORCH-065), не от кода 066. История ветки-068 по 066 содержит только `docs staging`, кода (`to_analyse`) нет.
4. **Таймлайн логов:** деплой 22:17 (ветка-066, сломанный reconciler) → спам ET-002 начался; деплой 05:32 (ветка-068, база 065 без 66) → спам прекратился (0 после 05:33). Подтверждает: прод-образ = снимок ВЕТКИ, меняется при каждом деплое, теряет незалитое.
## Root cause (гипотеза → нужен код-аудит self_deploy/merge_gate)
Self-deploy Phase B инициирует прод-деплой из worktree ветки (BUILD-ONCE из validated commit). Шаг git-merge ветки в main:
- ЛИБО не вызывается на self-hosting пути (Phase B уходит в detached host-процесс, finalizer пишет SUCCESS-маркеры, но merge отдельно и молча скипается/падает),
- ЛИБО регресс фикса ORCH-065 (idempotent merge / merge-lease reclaim): guard `pr_already_merged` или lease-reclaim ошибочно считает PR уже слитым / не докатывает merge после рестарта контейнера (а Phase B ИМЕННО рестартит контейнер → процесс, державший merge-lease, умирает до завершения merge).
Симптоматически ORCH-065 был последним успешным merge — деградация началась СРАЗУ после него или из-за взаимодействия его механики с self-deploy-рестартом.
## Почему конвейер не заметил
- `finalize SUCCESS` и `post-deploy HEALTHY` маркеры пишутся НЕЗАВИСИМО от факта merge. Пайплайн считает задачу done по этим маркерам, git-состояние main не верифицируется.
- Прод здоров (образ из ветки рабочий) → health-check зелёный → нет сигнала о проблеме.
- Дыра видна только при сравнении main с прод ИЛИ когда следующая задача теряет код предыдущей (что и случилось с 67).
## Уроки
1. **Деплой ОБЯЗАН верифицировать, что код реально в main ПОСЛЕ деплоя.** finalize SUCCESS без проверки `git merge-base origin/main == deployed_commit` (или PR.merged==true) — фальшивый зелёный. Добавить post-merge верификацию: deployed SHA должен быть предком origin/main.
2. **Маркер «deployed» != «merged».** Нельзя считать задачу завершённой по staging/post-deploy-маркерам, если PR не закрыт merge. Гейт: задача → done ТОЛЬКО при PR.merged==true.
3. **Self-deploy рестартит контейнер → любой держатель merge-lease/незавершённый git-шаг умирает.** Merge ДОЛЖЕН завершиться и быть подтверждён ДО рестарта прод-контейнера, либо merge выносится в шаг, переживающий рестарт (как requeue_running_jobs, но для merge-в-main).
4. **Срез ветки от main делает целостность main критичной.** Если main отстаёт — каждая новая задача наследует дыру. main = единственный источник для новых веток, его рассинхрон с прод = накопительная потеря.
5. **Метод диагностики (сохранить как runbook):** при подозрении на рассинхрон — (a) Gitea API PR list merged-флаги, (b) md5 prod-файлов vs `git show origin/main:<file>`, (c) merge-base ветки vs main, (d) таймлайн деплой-логов. Эти 4 проверки однозначно локализуют фантом.
## Действия
- Восстановление main: интеграционная ветка `integ/restore-main-2026-06-08` — последовательный merge 022→059→066→068 (docs union-resolved, reconciler-конфликт 066⊕068 разрешён: каркас 068 livelock-fix + триггер to_analyse 066), полный pytest, затем merge в main + передеплой.
- Заведён критбаг ORCH-071: «фантомный merge — self-deploy без верификации merge в main» (root-fix: post-deploy verify + done-гейт по PR.merged + merge до рестарта).
- ORCH-070 (Confirm Deploy trigger) частично ДУБЛИРУЕТ ORCH-059 (handle_confirm_deploy уже написан в 059) — после долива 059 пересмотреть scope 070 (остаётся только display-слой статусов Monitoring after Deploy).
## Связанные
- ORCH-065 (последний честный merge; подозрение на регресс его merge-механики)
- ORCH-066/068 (потерянный код), ORCH-059 (Confirm Deploy trigger, тоже потерян)
- Урок 2026-06-08 confirm-deploy-deadtrigger (симптом того же корня)

View File

@@ -0,0 +1,125 @@
# Runbook — диагностика «фантомного merge» (ORCH-071)
> **Когда применять.** Задача дошла до `done` (или прод задеплоен «зелёным»), но есть
> подозрение, что её ветка **не влита в `main`** — следующая задача срежет ветку от
> устаревшего `main` и потеряет код предшественника (постмортем
> `docs/history/LESSONS_2026-06-08_phantom-merge.md`). Этот runbook даёт 4 проверки
> для **однозначной локализации** фантома.
С ORCH-071 такой исход блокируется автоматически: под-гейт `deploy → done`
(`stage_engine._handle_merge_verify`) сначала **детерминированно вливает PR**
(`merge_gate.merge_pr`, Gitea PR-merge API), затем **верифицирует merge**
(`merge_gate.verify_merged_to_main`) и НЕ пускает задачу в `done`, пока merge не
подтверждён (alert + HOLD). Этот runbook — для ручной перепроверки/инцидентов
(в т.ч. при выключенном kill-switch `ORCH_MERGE_VERIFY_ENABLED=false`).
Подставьте значения:
```bash
OWNER=admin # settings.gitea_owner
REPO=orchestrator # репозиторий
BRANCH=feature/ORCH-071-slug # ветка задачи
GITEA=http://localhost:3000 # settings.gitea_url
TOKEN=<gitea_token> # settings.gitea_token
FILE=src/stage_engine.py # любой файл, гарантированно изменённый задачей
```
---
## Проверка 1 — Gitea API: список PR + флаги `merged`
Показывает, считает ли сам Gitea PR влитым.
```bash
curl -s -H "Authorization: token $TOKEN" \
"$GITEA/api/v1/repos/$OWNER/$REPO/pulls?state=all" \
| python3 -c 'import sys,json; \
[print(p["number"], p["state"], "merged="+str(p.get("merged")), p["head"]["ref"]) \
for p in json.load(sys.stdin)]'
```
* **Фантом НЕ подтверждён (всё хорошо):** строка ветки `$BRANCH` имеет `merged=True`.
* **Фантом подтверждён (по этому критерию):** PR ветки `state=open` / `merged=False`
(или PR отсутствует), при том что задача в `done` / прод задеплоен.
---
## Проверка 2 — md5 прод-файлов vs `git show origin/main:<file>`
Сверяет содержимое файла на проде с тем, что лежит в `origin/main`.
```bash
# в прод-контейнере (или через docker exec orchestrator):
md5sum "/app/$FILE"
# содержимое того же файла из origin/main (на хосте, в клоне репо):
git -C /home/slin/repos/$REPO fetch origin main -q
git -C /home/slin/repos/$REPO show "origin/main:$FILE" | md5sum
```
* **Совпало:** прод соответствует `main` (фантома нет ИЛИ задача не меняла этот файл —
возьмите файл из проверки 3/diff'а ветки).
* **Разошлось:** прод собран из ветки, а `main` его не получил → косвенный признак фантома.
---
## Проверка 3 — `git merge-base` ветки vs `main`
Главный детерминированный критерий: является ли HEAD ветки предком `origin/main`.
```bash
git -C /home/slin/repos/$REPO fetch origin -q
SHA=$(git -C /home/slin/repos/$REPO rev-parse "origin/$BRANCH")
git -C /home/slin/repos/$REPO merge-base --is-ancestor "$SHA" origin/main \
&& echo "MERGED: ветка влита в main" \
|| echo "NOT MERGED: ветка НЕ предок origin/main (ФАНТОМ)"
```
Это ровно та проверка, что выполняет `merge_gate.verify_merged_to_main` (rc=0 → влито).
* **`MERGED`:** фантома нет.
* **`NOT MERGED`:** фантом подтверждён — `main` не содержит коммитов задачи.
---
## Проверка 4 — таймлайн деплой-логов
Восстанавливает порядок событий: был ли merge до/после деплоя, и был ли он вообще.
```bash
# Вердикт деплоя + новое поле merge-верификации (ORCH-071):
git -C /home/slin/repos/$REPO show "origin/$BRANCH:docs/work-items/<WI>/14-deploy-log.md" \
| sed -n '1,12p' # frontmatter: deploy_status:, merged_to_main:
# Наблюдаемость под-гейта в живом сервисе:
curl -s "$GITEA_HEALTH/queue" | python3 -c \
'import sys,json; print(json.load(sys.stdin)["merge_verify"])'
# -> {"enabled":..., "merge_verified_total":..., "not_merged_alerts_total":..., "last_alert_wi":...}
# Журнал хоста по деплою (sentinel-каталог задачи):
ls -la /home/slin/repos/.deploy-state-$REPO/<WI>/
cat /home/slin/repos/.deploy-state-$REPO/<WI>/hook.log
```
* `deploy_status: SUCCESS` + `merged_to_main: false` → деплой прошёл, merge — нет
(это и есть класс ORCH-071; задача должна быть удержана на `deploy`, не `done`).
* `not_merged_alerts_total` растёт / `last_alert_wi == <WI>` → под-гейт уже поднял alert.
---
## Критерий «фантом подтверждён»
Фантомный merge считается **подтверждённым**, если выполняется ХОТЯ БЫ ОДНО из:
1. Проверка 1: PR ветки `state=open` / `merged=False` (или PR нет), а задача в `done`.
2. Проверка 3: `merge-base --is-ancestor` вернул **NOT MERGED** (HEAD ветки не предок `origin/main`).
3. Проверка 4: `14-deploy-log.md` имеет `deploy_status: SUCCESS` при `merged_to_main: false`.
Проверка 2 — вспомогательная (зависит от того, менял ли файл задачей), используется
для подтверждения проверок 1/3.
### Что делать при подтверждённом фантоме
1. **Влить PR вручную** через Gitea (PR-merge API / UI) — НИКОГДА не `git push`/`--force` в `main` (INV-4).
2. Повторить approve задачи (re-drive) — под-гейт переоценит: merge подтвердится → задача уйдёт в `done`.
3. Если фантом случился при выключенном kill-switch — включить `ORCH_MERGE_VERIFY_ENABLED=true`.

View File

@@ -0,0 +1,7 @@
# Business Request: [★ высокий] Security-гейт: secret-scanning + аудит зависимостей перед мержем
Work Item ID: ORCH-022
## Description
TBD

View File

@@ -0,0 +1,150 @@
# 01 — BRD: Security-гейт (secret-scanning + аудит зависимостей перед мержем)
Work Item: **ORCH-022**
Приоритет: **★ высокий**
Источник: предложение Стрим, одобрено Славой (2026-06-04).
Стадия: analysis.
---
## 1. Бизнес-проблема
Оркестратор — автономная мульти-агентная система: агенты (`developer`) пишут код
**без человека-фильтра по умолчанию**. Перед мержем в `main` сейчас нет проверки на:
- **утёкший секрет** — закоммиченный API-ключ / токен / пароль / приватный ключ;
- **дырявую зависимость** — пакет с известной CVE;
- (опционально) **базовую уязвимость кода** — типовой SAST-паттерн.
Для автономной системы это критично: ошибку, которую в обычной команде «выловили бы
глазами на ревью», здесь поймать некому. Утёкший в `git`-историю ключ или уязвимая
зависимость может уехать в прод и обслуживать **все** проекты (общий инстанс,
self-hosting).
### Прецеденты / связки
- **PR #18** (`check_ci_green`: красный CI → возврат на `development`) — задаёт целевой
паттерн поведения красного гейта. Security-гейт должен вести себя так же.
- **Управление секретами** (CLAUDE.md §8): секреты живут только в `.env`/`.env.staging`
на хосте, канон — `.env.example`. Гейт — это автоматический страж этого правила.
---
## 2. Цель
Ввести **security-гейт перед слиянием ветки задачи в `main`**, который детерминированно
(без LLM) проверяет diff/ветку на секреты и уязвимые зависимости и **блокирует
продвижение** при нарушении порогов: красный security-гейт → **возврат на `development`**
(developer-retry, как красный CI / merge-gate), задача **не уезжает в прод**.
### Бизнес-ценность
- Структурно невозможно «тихо» влить секрет или известную CVE в прод автономной системы.
- Самоприменение правила CLAUDE.md §8 (секреты не в гит) без участия человека.
- Расширяет уже выстроенную линию автономных страховок (CI-гейт, merge-gate ORCH-043,
staging-провенанс ORCH-058, post-deploy ORCH-021).
---
## 3. Объём (Scope)
### 3.1 В объёме (v1) — **предположение по умолчанию (A1)**
1. **Secret-scanning** — обязательный минимум гейта. Поиск закоммиченных секретов
в ветке задачи / её diff относительно `main`.
2. **Dependency audit** — аудит зависимостей проекта на известные CVE.
3. **Машиночитаемый артефакт-вердикт** security-гейта (YAML-frontmatter — канон гейтов).
4. **Поведение красного гейта** = откат на `development` + developer-retry (cap
`MAX_DEVELOPER_RETRIES = 3`), наблюдаемость (Telegram + Plane-коммент).
5. **Условный раскат** (kill-switch + scope репозиториев), **never-raise**,
self-hosting (`orchestrator`) — первым.
### 3.2 Вне объёма (v1) — **предположение (A2), отдельные WI**
- **SAST (semgrep)** — вынесен в follow-up WI: шумнее, требует policy-тюнинга правил;
гейт проектируется с точкой расширения под него, но в v1 не включается.
- **Полноценный мульти-стек** (JS/npm, Android) — см. A3 ниже; в v1 целевой стек —
Python (сам оркестратор). Связь с ORCH-9/15 фиксируется как зависимость на будущее.
- Ретроспективное сканирование уже существующей истории `main` (гейт смотрит вперёд —
ветку перед мержем, не чистит прошлое).
- Управление аллоулистом ложных срабатываний через UI/Plane (в v1 — файл в репозитории).
### 3.3 Зафиксированные предположения по умолчанию
> ⚠️ Интерактивный опрос Owner на стадии анализа не дал ответа; ниже —
> **дефолты по конвенциям проекта**. Любой из них Owner/архитектор может переопределить
> (для A4 предусмотрены конфиг-флаги порогов).
- **A1 (объём сканеров v1):** secret-scanning + dependency-audit. SAST отложен.
- **A2 (SAST):** отложен в отдельный WI; гейт оставляет точку расширения.
- **A3 (стек):** **Python-only сначала**, реально только для self-hosting
(`is_self_hosting_repo` / scope-CSV), как ORCH-35/43/58. Прочие репо — no-op pass.
Мульти-стек (детект стека по репо) — отдельный WI.
- **A4 (пороги):** **секреты — всегда блок**; **зависимости — блок на HIGH/CRITICAL,
warning на MEDIUM/LOW**. Пороги вынесены в конфиг (переопределяемы без редеплоя кода).
---
## 4. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Owner (Слава) | Прод-безопасность автономного конвейера; контроль порогов и раската. |
| Стрим | Инициатор; снижение риска утечки/уязвимости в автономном режиме. |
| Агент `developer` | Получает понятную причину красного гейта → быстрый фикс. |
| Агент `reviewer` | Гейт снимает с него непосильную задачу «глазами ловить ключи». |
| Все проекты на инстансе | Общий прод не должен получить секрет/CVE через одну задачу. |
---
## 5. Бизнес-требования
| ID | Требование | Приоритет |
|----|-----------|-----------|
| BR-1 | Перед слиянием ветки задачи в `main` обязателен security-гейт (секреты + аудит зависимостей). | MUST |
| BR-2 | Найден секрет (порог A4) → гейт **красный** → откат на `development`, в прод не уходит. | MUST |
| BR-3 | Уязвимость зависимости уровня блокировки (порог A4) → гейт **красный** → откат на `development`. | MUST |
| BR-4 | Уязвимость ниже порога блокировки → **warning**, продвижение не блокируется, но фиксируется в артефакте. | MUST |
| BR-5 | Красный гейт ведёт себя как красный CI / merge-gate: откат на `development` + developer-retry (cap 3), затем эскалация (Telegram + Plane Blocked). | MUST |
| BR-6 | Вердикт гейта — **машиночитаемый** (YAML-frontmatter артефакта), читается гейтом ТОЛЬКО из frontmatter (канон проекта), не из прозы. | MUST |
| BR-7 | Гейт **детерминированный, без LLM** в критическом пути (как merge-gate / image-freshness). | MUST |
| BR-8 | Гейт **never-raise**: внутренняя ошибка не роняет `advance_stage` и не вешает конвейер всех проектов. | MUST |
| BR-9 | Условный раскат: глобальный kill-switch + scope-CSV репозиториев; пусто → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. | MUST |
| BR-10 | Пороги блокировки конфигурируемы (env-флаги, без редеплоя кода). | SHOULD |
| BR-11 | Наблюдаемость: причина блокировки видна (Telegram + Plane-коммент + артефакт); проход — без шума. | MUST |
| BR-12 | Документация (CLAUDE.md «Артефакты задачи», `docs/architecture/README.md` таблица гейтов, CHANGELOG, ADR) обновлена в том же PR. | MUST |
| BR-13 | Аллоулист ложных срабатываний (заведомо-безопасные совпадения, напр. в `.env.example`, фикстуры тестов) поддерживается версионируемым файлом в репозитории. | SHOULD |
| BR-14 | Точка расширения под SAST и мульти-стек заложена, но в v1 не активна (A2/A3). | SHOULD |
---
## 6. Ограничения и риски (бизнес-уровень)
- **Self-hosting:** гейт исполняется внутри инстанса, который правит сам себя. Запрет на
рестарт/падение прод-контейнера в рамках задачи (CLAUDE.md §self-hosting) сохраняется —
гейт ничего не деплоит и не рестартит, только читает/сканирует.
- **Ложные срабатывания** (false positives) могут зациклить откат `→ development`
(прецедент ORCH-061 со staging-петлёй). Митигировано: cap retry=3 + аллоулист (BR-13)
+ конфигурируемые пороги (BR-10) + kill-switch (BR-9).
- **Внешние БД уязвимостей** (CVE-фиды) — сетевая зависимость; недоступность фида не
должна давать ложный красный (см. AC: degrade-поведение при недоступности фида —
решение порога «fail-open vs fail-closed для аудита» закрепляется в acceptance + ADR).
- **Стоимость/время** сканирования добавляется к каждому прогону задачи — должно быть
ограничено таймаутом (как merge-retest).
---
## 7. Критерий успеха (бизнес)
Ветка с подсаженным тестовым секретом и/или зависимостью с известной CRITICAL-CVE
**не может** дойти до `main`/прода: гейт краснеет, задача откатывается на `development`
с понятной причиной. Чистая ветка проходит гейт без задержек и без шума. Для не-self
репозиториев конвейер не меняется (no-op). Прод-контейнер не рестартится гейтом.
---
## 8. Открытые вопросы (для архитектора / Owner)
1. **Размещение гейта** (решение архитектора): (а) на стадии `review`, либо (б) отдельный
под-гейт перед мержем на ребре `deploy-staging → deploy` (где уже живёт merge-gate
ORCH-043 / image-freshness ORCH-058). Требование BRD — «перед слиянием в `main`»;
обе опции его удовлетворяют. См. 02-trz §4.
2. **Где запускается сканер**: новый job в `.gitea/workflows/ci.yml` (тогда вердикт может
течь через существующий `check_ci_green`) **или** отдельный QG-чек/под-гейт в `src/qg`.
Решение — архитектор (02-trz фиксирует требования к обоим путям).
3. **Аудит зависимостей при недоступном CVE-фиде:** fail-open (warning) или fail-closed
(блок)? Дефолт-предложение — **fail-open с громким warning** (не плодить ложные
завороты), закрепить в ADR.
4. **Выбор конкретных инструментов** (gitleaks vs trufflehog; pip-audit vs trivy) —
технологическое решение архитектора; BRD фиксирует только функцию.

View File

@@ -0,0 +1,175 @@
# 02 — ТЗ: Security-гейт (secret-scanning + dependency audit)
Work Item: **ORCH-022** · Стадия: analysis · См. `01-brd.md`, `03-acceptance-criteria.md`.
> **Граница ответственности аналитика.** Ниже — *функциональные требования и точки
> касания* кода. Выбор размещения гейта в пайплайне, конкретных инструментов и схемы
> модулей — **решение архитектора** (см. §4 и `01-brd.md` §8). ТЗ фиксирует требования к
> любому из допустимых вариантов и инварианты, которые нельзя нарушать.
---
## 1. Контекст кода (как есть)
- **Стадии:** `src/stages.py::STAGE_TRANSITIONS` — линейный конвейер
`… review → testing → deploy-staging → deploy → done`. Фактический merge ветки в
`main` делает агент `deployer` **в начале стадии `deploy`** (CLAUDE/README).
- **Quality Gates:** `src/qg/checks.py` — реестр `QG_CHECKS` (имя → функция), сигнатуры
диспетчеризуются в `src/stage_engine.py::_run_qg`.
- **Существующий паттерн «красный гейт → возврат developer»:**
`check_ci_green` (PR #18) и rollback-ветки в
`stage_engine._handle_qg_failure_rollbacks` (откат на `development`, developer-retry,
cap `MAX_DEVELOPER_RETRIES = 3`, затем `set_issue_blocked` + Telegram).
- **Эталонный паттерн детерминированного под-гейта на ребре** (без LLM, never-raise,
условный раскат, откат на `development`):
- merge-gate **ORCH-043**`src/merge_gate.py` + `check_branch_mergeable` +
`stage_engine._handle_merge_gate` (ребро `deploy-staging → deploy`);
- image-freshness **ORCH-058**`src/image_freshness.py` + `_check_staging_image_fresh`
+ `stage_engine._handle_image_freshness` (то же ребро).
Оба: leaf-модуль с чистой логикой (never-raise) + тонкая обёртка в `QG_CHECKS` +
врезка-обработчик в `advance_stage`, kill-switch `*_enabled` + scope `*_repos`,
реально только для self-hosting при пустом scope.
- **CI:** `.gitea/workflows/ci.yml` — один job `test` (pytest) на `self-hosted` раннере,
push в `feature/**` и PR в `main`. `check_ci_green` читает комбинированный статус
коммита из Gitea API.
- **Артефакты задачи** нумерованы до `16-post-deploy-log.md`.
- **Зависимости Python:** `requirements.txt` (корень репо).
---
## 2. Функциональные требования к реализации
### FR-1. Secret-scanning ветки перед мержем
- Сканировать ветку задачи / её diff относительно `origin/main` на секреты
(ключи, токены, пароли, приватные ключи).
- **Любой** подтверждённый секрет (не из аллоулиста) → вердикт **FAIL** (порог A4: секреты
всегда блокируют).
- Инструмент (gitleaks / trufflehog) — выбор архитектора. Должен запускаться offline-/
детерминированно (без LLM) и иметь конфиг правил/аллоулиста в репозитории.
### FR-2. Dependency audit
- Аудит зависимостей целевого стека на известные CVE. Для Python — манифест
`requirements.txt` (инструмент pip-audit / trivy — выбор архитектора).
- Классификация по severity. **Порог блокировки (A4, конфигурируемо BR-10):**
- `CRITICAL`, `HIGH` → вклад в **FAIL**;
- `MEDIUM`, `LOW`**warning** (фиксируется в артефакте, не блокирует).
- Недоступность CVE-фида: degrade-поведение по решению ADR (дефолт-предложение —
fail-open + громкий warning, чтобы не плодить ложные завороты). Поведение должно быть
детерминированным и протестированным.
### FR-3. Машиночитаемый артефакт-вердикт
- Гейт порождает артефакт security-отчёта с **YAML-frontmatter**, напр.:
```
---
security_status: PASS # PASS | FAIL
secrets_found: 0
deps_blocking: 0 # число уязвимостей уровня блокировки
deps_warning: 2
---
```
Имя артефакта — предложение: **`17-security-report.md`** (следующий свободный номер;
финализирует архитектор). Тело — человекочитаемый список находок.
- Вердикт читается гейтом **ТОЛЬКО из frontmatter** (канон проекта: «машинные вердикты —
строго YAML-frontmatter, никогда проза»), по образцу `_parse_deploy_status` /
`_parse_staging_status` / `check_reviewer_verdict`. Negative-токен (FAIL) авторитетен.
- Отсутствие/битый frontmatter → `(False, reason)` (fail-closed на чтении вердикта,
как у существующих парсеров).
### FR-4. Поведение красного гейта (откат)
- `security_status: FAIL` → откат на `development` + enqueue `developer`, по образцу
`_handle_qg_failure_rollbacks` (merge-gate-ветка — точный шаблон):
- cap `MAX_DEVELOPER_RETRIES` (3); при исчерпании — `set_issue_blocked` + Telegram-алерт;
- `task_desc` для developer несёт **дословную причину** (какие секреты/CVE), по образцу
ORCH-046 (встраивание must-fix в `task_desc`), а не только ссылку на артефакт;
- Plane-коммент + `notify_qg_failure` (наблюдаемость BR-11).
### FR-5. Условный раскат (как ORCH-35/43/58)
- Глобальный kill-switch `security_gate_enabled` (env `ORCH_SECURITY_GATE_ENABLED`,
дефолт по согласованию; рекомендуется `true` с safety-net, как у соседних фич).
- Scope `security_gate_repos` (CSV); пусто → реально только `is_self_hosting_repo(repo)`
(`orchestrator`). Прочие репо → `(True, "security-gate N/A for <repo>")` (мгновенный pass).
- Отдельные пороги-флаги (A4/BR-10): напр. `security_dep_block_severity`
(`HIGH` по умолчанию), при желании `security_secrets_block` (`true`).
### FR-6. never-raise
- Любая внутренняя ошибка гейта (сбой сканера, отсутствие бинаря, таймаут) →
`(False, "<reason>")` **без** проброса исключения в `advance_stage`. Контракт —
как у `check_branch_mergeable` (внешний + внутренний guard).
- Таймаут сканирования ограничен (по образцу `merge_retest_timeout_s`).
### FR-7. Наблюдаемость
- Блокировка → Telegram + Plane-коммент (BR-11). Проход → лог-строка, без шумных
нотификаций (по образцу merge-gate pass).
- Желательно: краткий снимок в `GET /queue` (опционально, по образцу блоков `reconcile`/
`reaper`/`post_deploy`) — на усмотрение архитектора.
---
## 3. Задействованные модули `src/` (точки касания)
| Модуль | Изменение |
|--------|-----------|
| `src/security_gate.py` (**новый leaf-модуль**) | Чистая логика гейта: запуск сканеров, классификация по severity, применение порогов/аллоулиста, формирование вердикта + парсер frontmatter. **never-raise.** По образцу `src/merge_gate.py` / `src/image_freshness.py` / `src/post_deploy.py`. |
| `src/qg/checks.py` | Новый чек `check_security_gate` (тонкая обёртка над `security_gate`, ленивый импорт во избежание циклов) + регистрация в `QG_CHECKS`. Условность (kill-switch/scope/self-hosting) — как `check_branch_mergeable` / `_check_staging_image_fresh`. |
| `src/stage_engine.py` | Врезка-обработчик `_handle_security_gate(...)` по образцу `_handle_merge_gate` / `_handle_image_freshness`: вызов в `advance_stage` на выбранном архитектором ребре; FAIL → откат на `development` (FR-4); never-raise. **`STAGE_TRANSITIONS` НЕ меняется**, если выбран вариант «под-гейт ребра». |
| `src/config.py` | Новые настройки: `security_gate_enabled`, `security_gate_repos`, `security_dep_block_severity`, `security_scan_timeout_s` (+ при необходимости пути к бинарям/конфигам сканеров). С docstring-комментариями по образцу ORCH-043/058. |
| `.gitea/workflows/ci.yml` | **Если** архитектор выберет CI-путь: новый job `security` (secret-scan + dep-audit), влияющий на комбинированный статус коммита (тогда срабатывает `check_ci_green`-паттерн PR #18). Иначе — не трогается. |
| `requirements.txt` / Dockerfile | Установка выбранных сканеров (если они Python-пакеты — в `requirements.txt`; если бинари — в Dockerfile/раннер). |
| Конфиг сканера + аллоулист | Версионируемые файлы в репозитории (напр. `.gitleaks.toml` / аллоулист) — BR-13. |
| `.openclaw/agents/developer.md` | (Если нужно) краткая инструкция developer'у про устранение security-находок при заворотах. |
> Если выбран вариант «гейт на стадии `review`» — врезка делается в соответствующую
> ветку `advance_stage`/обработчик ревью вместо ребра `deploy-staging → deploy`.
---
## 4. Размещение в пайплайне — варианты для архитектора
Требование BRD: **«перед слиянием ветки в `main`»**. Допустимы (выбор + обоснование — в ADR):
- **Вариант R (review):** security-проверка на стадии `review` (раньше отлов, дешевле
откат — задача ещё близко к development). Минус: дальше по конвейеру `main` может уйти
вперёд (но это закрывает merge-gate).
- **Вариант M (merge-edge, рекомендуемый к рассмотрению):** под-гейт на ребре
`deploy-staging → deploy`, рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058) —
непосредственно перед фактическим мержем `deployer`'ом. Плюс: единое место «последней
страховки перед main», переиспользование готового паттерна врезки/отката/lease.
- **Вариант C (CI-job):** добавить job в `ci.yml`; вердикт течёт через `check_ci_green`.
Плюс: меньше нового кода в движке. Минус: пороги/severity-логика и артефакт-вердикт
сложнее выразить только статусом коммита.
ТЗ не предписывает вариант; реализация обязана сохранить инварианты §6.
---
## 5. Изменения API
- Новых HTTP-endpoint'ов **не требуется**.
- Допустимо (опционально, FR-7): расширить ответ `GET /queue` блоком `security`
(counts/last_run) — по образцу блоков `reconcile`/`reaper`/`post_deploy`. Не обязательно.
## 6. Изменения схемы БД
- **Не требуется.** Состояние гейта — артефакт-файл + (при необходимости) sentinel-файлы,
по образцу merge-lease / deploy-state / post-deploy-state. Миграций БД нет.
- Если архитектор сочтёт нужным считать security-retry отдельно от developer-retry —
предпочесть подсчёт по `jobs`/`agent_runs` (как `_developer_retry_count` /
`_merge_defer_count`), без новых колонок.
## 7. Инварианты (НЕ нарушать)
1. `STAGE_TRANSITIONS` и реестр `QG_CHECKS` остаются консистентными; при варианте
«под-гейт ребра» — `STAGE_TRANSITIONS` не меняется (триггер — то же событие стадии).
2. Машинный вердикт — только из YAML-frontmatter, не из прозы.
3. never-raise: гейт никогда не пробрасывает исключение в `advance_stage`.
4. Условность как ORCH-35/43/58: не-self репо при пустом scope не затрагиваются (no-op).
5. Гейт **не деплоит и не рестартит** прод-контейнер (self-hosting safety).
6. Откат и retry-счётчик developer не ломаются (cap=3, затем эскалация).
7. Документация (CLAUDE.md, README, CHANGELOG, ADR) обновлена в том же PR (BR-12).
## 8. Артефакты pipeline, создаваемые/обновляемые
- **Новый:** `docs/work-items/ORCH-022/17-security-report.md` (имя финализирует архитектор)
с `security_status:`-frontmatter (FR-3) — порождается гейтом per-task.
- **ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-<slug>.md` (решение: размещение,
инструменты, degrade-поведение фида, пороги). При сквозном влиянии — global ADR в
`docs/architecture/adr/`.
- **Обновить:** `CLAUDE.md` (раздел «Артефакты задачи» — добавить 17-…),
`docs/architecture/README.md` (таблица гейтов + реестр `QG_CHECKS` + новый раздел),
`CHANGELOG.md`, `.env.example` (новые `ORCH_SECURITY_*`).

View File

@@ -0,0 +1,140 @@
# 03 — Критерии приёмки: Security-гейт (ORCH-022)
Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Привязка к
`01-brd.md` (BR-*) и `02-trz.md` (FR-*).
---
## A. Secret-scanning (FR-1, BR-1/BR-2)
### AC-1 — Подсаженный секрет блокирует гейт
- **PASS:** ветка с тестовым секретом (напр. фиктивный AWS-ключ формата `AKIA…` вне
аллоулиста) → `security_status: FAIL`; гейт возвращает `(False, reason)`, причина
называет секрет/файл.
- **FAIL:** секрет не обнаружен ИЛИ гейт зелёный при наличии секрета.
### AC-2 — Чистая ветка проходит
- **PASS:** ветка без секретов → `security_status: PASS`; `secrets_found: 0`;
гейт возвращает `(True, …)`.
- **FAIL:** ложное срабатывание (FAIL на чистой ветке).
### AC-3 — Аллоулист подавляет заведомо-безопасное (BR-13)
- **PASS:** совпадение, явно занесённое в версионируемый аллоулист (напр. плейсхолдер в
`.env.example` / фикстура теста), **не** даёт FAIL.
- **FAIL:** аллоулист игнорируется и даёт ложный FAIL.
---
## B. Dependency audit (FR-2, BR-3/BR-4)
### AC-4 — CVE уровня блокировки краснит гейт
- **PASS:** зависимость с известной `CRITICAL`/`HIGH` CVE (при пороге по умолчанию
`HIGH`) → вклад в `security_status: FAIL`; `deps_blocking >= 1`.
- **FAIL:** блокирующая уязвимость не приводит к FAIL.
### AC-5 — Низкая severity = warning, не блок
- **PASS:** только `MEDIUM`/`LOW` уязвимости → `security_status: PASS`, при этом
`deps_warning >= 1` и находки перечислены в теле артефакта.
- **FAIL:** `MEDIUM`/`LOW` блокирует продвижение.
### AC-6 — Порог блокировки конфигурируем (BR-10)
- **PASS:** при `ORCH_SECURITY_DEP_BLOCK_SEVERITY=CRITICAL` та же `HIGH`-уязвимость
становится warning (не блок); при `=HIGH` — блок. Поведение детерминированно
определяется флагом.
- **FAIL:** флаг не влияет на классификацию.
### AC-7 — Degrade при недоступном CVE-фиде
- **PASS:** недоступность фида обрабатывается по решению ADR детерминированно и
протестированно (дефолт: fail-open + громкий warning, гейт не краснеет ложно).
- **FAIL:** недоступность фида даёт неконтролируемый красный/исключение.
---
## C. Вердикт и артефакт (FR-3, BR-6)
### AC-8 — Машинный вердикт только из frontmatter
- **PASS:** вердикт читается ТОЛЬКО из YAML-frontmatter `17-security-report.md`; проза с
«PASS»/«FAIL» в теле не влияет на решение. Negative-токен (FAIL) авторитетен.
- **FAIL:** вердикт извлекается из тела/прозы.
### AC-9 — Битый/отсутствующий frontmatter → fail-closed на чтении
- **PASS:** нет frontmatter / битый YAML / нет поля `security_status``(False, reason)`
(как `_parse_deploy_status`/`check_reviewer_verdict`).
- **FAIL:** битый артефакт трактуется как PASS.
### AC-10 — Артефакт создаётся с корректными полями
- **PASS:** после прогона существует `17-security-report.md` с валидным frontmatter
(`security_status`, `secrets_found`, `deps_blocking`, `deps_warning`) и телом-списком.
- **FAIL:** артефакт не создан/без машинных полей.
---
## D. Откат и retry (FR-4, BR-5)
### AC-11 — Красный гейт → откат на development + developer-retry
- **PASS:** `FAIL` → стадия задачи становится `development`, enqueue `developer`,
Plane-коммент + `notify_qg_failure`; счётчик developer-retry растёт.
- **FAIL:** при FAIL задача продвигается дальше / не откатывается.
### AC-12 — task_desc несёт дословную причину (ORCH-046-паттерн)
- **PASS:** `task_desc` для перезапущенного developer содержит конкретику находок
(какие секреты/CVE), а не только ссылку на артефакт.
- **FAIL:** developer получает только ссылку без сути.
### AC-13 — Cap retry и эскалация
- **PASS:** после `MAX_DEVELOPER_RETRIES` (3) безуспешных фиксов — `set_issue_blocked` +
Telegram-алерт; бесконечного отскока нет.
- **FAIL:** откат зацикливается без cap/эскалации.
---
## E. Условный раскат и устойчивость (FR-5/FR-6, BR-8/BR-9)
### AC-14 — Не-self репозиторий = no-op pass
- **PASS:** для repo, не входящего в scope и не self-hosting → гейт возвращает
`(True, "security-gate N/A for <repo>")` мгновенно, конвейер такого репо не меняется.
- **FAIL:** гейт реально запускается/блокирует чужой репо при пустом scope.
### AC-15 — Kill-switch отключает гейт
- **PASS:** `ORCH_SECURITY_GATE_ENABLED=false` → гейт — no-op pass (`(True, …)`),
поведение конвейера 1:1 как до ORCH-022.
- **FAIL:** при выключенном флаге гейт всё ещё блокирует.
### AC-16 — never-raise
- **PASS:** искусственный сбой (нет бинаря сканера / таймаут / исключение внутри) →
`(False, reason)` без проброса исключения; `advance_stage` не падает, конвейер других
задач/проектов не встаёт.
- **FAIL:** внутренняя ошибка пробрасывается/вешает движок.
### AC-17 — Таймаут ограничен
- **PASS:** сканирование, превысившее `ORCH_SECURITY_SCAN_TIMEOUT_S`, корректно
прерывается → детерминированный вердикт (по политике degrade), без зависания.
- **FAIL:** сканер висит без таймаута.
---
## F. Инварианты и интеграция (BR-7/BR-12, TRZ §7)
### AC-18 — STAGE_TRANSITIONS/QG_CHECKS консистентны
- **PASS:** при варианте «под-гейт ребра» `STAGE_TRANSITIONS` не изменён; новый чек
зарегистрирован в `QG_CHECKS`; `_run_qg` корректно его диспетчеризует. Все
существующие тесты гейтов/стадий зелёные.
- **FAIL:** сломан реестр/переходы/существующие тесты.
### AC-19 — Гейт не деплоит/не рестартит прод
- **PASS:** код гейта не вызывает деплой-хук/рестарт прод-контейнера; только
чтение/сканирование.
- **FAIL:** гейт инициирует рестарт/деплой.
### AC-20 — Документация обновлена в том же PR (BR-12)
- **PASS:** обновлены `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md`
(таблица гейтов + реестр QG + раздел ORCH-022), `CHANGELOG.md`, `.env.example`
(`ORCH_SECURITY_*`); заведён ADR `06-adr/ADR-001-*`.
- **FAIL:** функционал есть, документация/ADR не обновлены → reviewer обязан
REQUEST_CHANGES (CLAUDE.md §6).
### AC-21 — End-to-end на тестовой задаче
- **PASS:** прогон на self-hosting-репо: грязная ветка (секрет/CVE) → откат на
`development`; после фикса чистая ветка → гейт зелёный → конвейер идёт дальше; прод не
затронут в процессе.
- **FAIL:** любой шаг E2E не воспроизводится.

View File

@@ -0,0 +1,126 @@
work_item: ORCH-022
title: "Security-гейт: secret-scanning + dependency audit перед мержем"
notes: >
План тестов для security-гейта. Чистая логика выносится в leaf-модуль
src/security_gate.py (never-raise) — основной предмет unit-тестов (по образцу
tests для merge_gate / image_freshness / post_deploy / staging_verdict).
Интеграция врезки в advance_stage и условный раскат — integration-тесты.
Имена модулей тестов финализирует разработчик/архитектор по факту реализации.
tests:
# --- Secret-scanning (FR-1 / AC-1..AC-3) ---
- id: TC-01
type: unit
description: "Подсаженный тестовый секрет в diff -> вердикт FAIL, secrets_found>=1, причина называет находку."
module: tests/test_security_gate.py
expected: PASS
- id: TC-02
type: unit
description: "Чистая ветка без секретов -> вердикт PASS, secrets_found=0."
module: tests/test_security_gate.py
expected: PASS
- id: TC-03
type: unit
description: "Совпадение из аллоулиста (плейсхолдер .env.example / фикстура) НЕ даёт FAIL."
module: tests/test_security_gate.py
expected: PASS
# --- Dependency audit + пороги (FR-2 / AC-4..AC-7) ---
- id: TC-04
type: unit
description: "CVE уровня HIGH/CRITICAL при пороге HIGH -> вклад в FAIL, deps_blocking>=1."
module: tests/test_security_gate.py
expected: PASS
- id: TC-05
type: unit
description: "Только MEDIUM/LOW уязвимости -> PASS, deps_warning>=1, находки в теле артефакта."
module: tests/test_security_gate.py
expected: PASS
- id: TC-06
type: unit
description: "Конфиг порога: severity=CRITICAL делает HIGH-CVE warning; severity=HIGH делает её блоком."
module: tests/test_security_gate.py
expected: PASS
- id: TC-07
type: unit
description: "Недоступный CVE-фид -> детерминированный degrade по политике ADR (дефолт fail-open + warning), без исключения и без ложного FAIL."
module: tests/test_security_gate.py
expected: PASS
# --- Вердикт / парсер frontmatter (FR-3 / AC-8..AC-10) ---
- id: TC-08
type: unit
description: "Вердикт читается ТОЛЬКО из YAML-frontmatter; проза PASS/FAIL в теле не влияет; negative-токен авторитетен."
module: tests/test_security_gate.py
expected: PASS
- id: TC-09
type: unit
description: "Нет frontmatter / битый YAML / нет поля security_status -> (False, reason) (fail-closed на чтении)."
module: tests/test_security_gate.py
expected: PASS
- id: TC-10
type: unit
description: "Артефакт 17-security-report.md создаётся с валидным frontmatter (security_status, secrets_found, deps_blocking, deps_warning) и телом-списком."
module: tests/test_security_gate.py
expected: PASS
# --- never-raise / таймаут / условность (FR-5/FR-6 / AC-14..AC-17) ---
- id: TC-11
type: unit
description: "Отсутствие бинаря сканера / внутреннее исключение -> (False, reason), исключение не пробрасывается (never-raise)."
module: tests/test_security_gate.py
expected: PASS
- id: TC-12
type: unit
description: "Превышение ORCH_SECURITY_SCAN_TIMEOUT_S -> корректное прерывание и детерминированный вердикт, без зависания."
module: tests/test_security_gate.py
expected: PASS
- id: TC-13
type: unit
description: "check_security_gate: не-self репо при пустом scope -> (True, 'security-gate N/A for <repo>') мгновенно."
module: tests/test_qg_security.py
expected: PASS
- id: TC-14
type: unit
description: "check_security_gate: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True)."
module: tests/test_qg_security.py
expected: PASS
- id: TC-15
type: unit
description: "Новый чек зарегистрирован в QG_CHECKS и корректно диспетчеризуется _run_qg."
module: tests/test_qg_security.py
expected: PASS
# --- Откат / retry в stage_engine (FR-4 / AC-11..AC-13) ---
- id: TC-16
type: integration
description: "security_status FAIL -> advance_stage откатывает на development, enqueue developer, Plane-коммент + notify_qg_failure."
module: tests/test_stage_engine_security_gate.py
expected: PASS
- id: TC-17
type: integration
description: "task_desc перезапущенного developer содержит дословную причину находок (ORCH-046-паттерн), не только ссылку."
module: tests/test_stage_engine_security_gate.py
expected: PASS
- id: TC-18
type: integration
description: "После MAX_DEVELOPER_RETRIES (3) -> set_issue_blocked + Telegram-алерт; бесконечного отскока нет."
module: tests/test_stage_engine_security_gate.py
expected: PASS
- id: TC-19
type: integration
description: "security_status PASS -> advance_stage продвигает конвейер штатно (без отката, без шумных нотификаций)."
module: tests/test_stage_engine_security_gate.py
expected: PASS
# --- Инварианты / интеграция (BR-7/BR-12 / AC-18..AC-19) ---
- id: TC-20
type: integration
description: "При варианте 'под-гейт ребра' STAGE_TRANSITIONS не изменён; существующие тесты стадий/гейтов остаются зелёными."
module: tests/test_stages.py
expected: PASS
- id: TC-21
type: integration
description: "Гейт не вызывает деплой-хук/рестарт прод-контейнера (self-hosting safety)."
module: tests/test_stage_engine_security_gate.py
expected: PASS

View File

@@ -0,0 +1,235 @@
# ADR-001: Security-гейт — secret-scanning + dependency audit перед мержем
- **Статус:** Accepted (proposed → принято архитектором ORCH-022)
- **Дата:** 2026-06-07
- **Задача:** ORCH-022
- **Связанный global ADR:** `docs/architecture/adr/adr-0012-security-gate.md`
- **Источники:** `01-brd.md` (BR-1..BR-14), `02-trz.md` (FR-1..FR-7, §4 варианты, §7 инварианты),
`03-acceptance-criteria.md` (AC-1..AC-21).
---
## Контекст
Оркестратор автономен: `developer`-агент пишет код без человека-фильтра. Перед слиянием
ветки задачи в `main` нет автоматической проверки на утёкший секрет (ключ/токен/пароль/
приватный ключ) и на уязвимую зависимость (известная CVE). Для self-hosting это особенно
опасно: один общий прод-инстанс обслуживает все проекты с общей БД — секрет или CVE,
просочившийся через одну задачу, попадает в прод всех проектов (CLAUDE.md §self-hosting, §8).
Конвейер уже содержит линию детерминированных страховок на ребре `deploy-staging → deploy`
(непосредственно перед фактическим мержем PR в `main`, который делает `deployer` в начале
стадии `deploy`):
- **merge-gate** (ORCH-043, `check_branch_mergeable`) — догон `main` + re-test + сериализация;
- **image-freshness** (ORCH-058, `check_staging_image_fresh`) — провенанс staging-образа.
Оба построены по одному паттерну: **leaf-модуль чистой логики (never-raise) + тонкая обёртка
в `QG_CHECKS` + врезка-обработчик `_handle_*` в `advance_stage`**, с условным раскатом
(`*_enabled` + `*_repos`, реально только для self-hosting при пустом scope) и откатом на
`development` с developer-retry (cap `MAX_DEVELOPER_RETRIES = 3`).
Открытые вопросы BRD §8 / TRZ §4, требующие решения архитектора:
1. Размещение гейта в пайплайне (review / merge-edge / CI-job).
2. Где запускается сканер (CI-job через `check_ci_green` / отдельный QG-чек).
3. Degrade при недоступном CVE-фиде (fail-open / fail-closed).
4. Выбор инструментов (gitleaks/trufflehog; pip-audit/trivy).
---
## Решение
### Р-1. Размещение — Вариант M (под-гейт ребра `deploy-staging → deploy`), ПЕРВЫМ среди edge-под-гейтов
Security-гейт реализуется как **детерминированный под-гейт того же ребра**
`deploy-staging → deploy`, что merge-gate и image-freshness, и исполняется **ПЕРВЫМ**
**ДО** merge-gate. `STAGE_TRANSITIONS` **не меняется** (триггер — то же событие «staging-
deployer завершился»; инвариант TRZ §7.1).
Порядок врезок в `advance_stage` (блок `current_stage == "deploy-staging"`):
```
check_staging_status (PASS, существующий QG стадии)
→ security-gate (НОВЫЙ, _handle_security_gate) ← первым
→ merge-gate (_handle_merge_gate)
→ image-freshness (_handle_image_freshness)
→ Phase A (self-deploy approve)
```
**Почему merge-edge, а не review (Вариант R):**
- BRD-требование «перед слиянием в `main`» удовлетворяют оба, но на review-стадии diff
может разойтись с тем, что реально вольётся в `main` (параллельная задача двигает `main`
вперёд между review и merge). Merge-edge — последняя точка перед фактическим мержем.
- Переиспользуется готовая машинерия отката/retry/нотификаций edge-под-гейтов
(минимальный blast-radius, инвариант TRZ §7).
**Почему ПЕРВЫМ (до merge-gate), а не после image-freshness:**
- **Дёшево фейлить.** merge-gate (rebase + re-test, минуты) и image-freshness (docker
rebuild, до 1200с) — дорогие. Нет смысла гонять их на ветке с секретом/CVE.
- **Корректность для секретов.** Секрет живёт в собственных коммитах ветки;
rebase онто `main` его не добавляет и не убирает → скан диапазона `origin/main..HEAD`
до rebase ловит ровно те коммиты, что попадут в `main`.
- **Анти-петля для зависимостей.** Аудит ветки **до** rebase оценивает то, что вносит
ИМЕННО эта задача (её `requirements.txt`/diff), а не уязвимость, которую притащил в
ветку обновившийся `main`. Аудит после rebase «обвинял» бы задачу в чужой (main'овой)
CVE → ложный откат `→ development` → петля (прецедент ORCH-061). Скан до rebase этого
избегает.
- **Проще, чем image-freshness.** Гейт исполняется ДО захвата merge-lease → при FAIL
**lease освобождать не нужно** (в отличие от `_handle_image_freshness`). Чистый откат.
**Почему не CI-job (Вариант C):** пороги severity, warning-vs-block, аллоулист и
машиночитаемый артефакт-вердикт плохо выражаются одним статусом коммита Gitea; путь
коуплится с CI-раннером. Отклонено для v1; оставлено как точка расширения (BR-14).
### Р-2. Инструменты
- **Secret-scanning — `gitleaks`.** Полностью **offline** (без сетевого фида → гарантия
«секрет всегда блокирует» не зависит от сети, BR-2), один статический бинарь,
детерминированный, конфиг + аллоулист в репо (`.gitleaks.toml`, BR-13), поддержка
`--log-opts="origin/main..HEAD"` (скан диапазона), JSON-отчёт, exit-code контракт
(0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента). Бинарь устанавливается в
`Dockerfile` (Go-бинарь, не pip-пакет) — см. `07-infra-requirements.md`.
- **Dependency audit — `pip-audit`.** Python-native (v1-стек — сам оркестратор, Python),
читает `requirements.txt`, источник advisory — OSV/PyPI, JSON-выход, ставится через
`requirements.txt`. trivy/trufflehog отклонены как тяжелее/контейнер-ориентированные для
v1-цели «Python-only» (A3).
Конкретные инструменты — деталь реализации; контракт гейта (вход: repo/branch/wi,
выход: `(bool, reason)` + артефакт) от них не зависит, заменяемы за leaf-модулем.
### Р-3. Degrade при недоступном CVE-фиде — **fail-open + громкий warning** (дефолт)
`pip-audit` требует сети (OSV/PyPI advisory DB). Недоступность фида **по умолчанию**:
- **fail-open**: dep-audit не даёт FAIL по причине недоступности фида (иначе — ложные
откаты `→ development` → петля при сетевых проблемах прод-инстанса, прецедент ORCH-061);
- **громко**: в артефакте `deps_audit_degraded: true`, лог `logger.warning`, Telegram-алерт.
- **Секреты не деградируют:** gitleaks offline → гарантия BR-2 безусловна даже при
отсутствии сети. Деградирует ТОЛЬКО dep-audit.
- **Конфигурируемо:** флаг `security_dep_audit_fail_closed` (дефолт `false`) позволяет
Owner'у переключить на fail-closed (недоступность фида → FAIL) без редеплоя кода.
Это разделяет две гарантии: «нет секрета в прод» — **безусловная**; «нет известной CVE» —
**best-effort при доступности фида**. Закреплено в acceptance (AC-7).
### Р-4. Пороги классификации (A4, BR-10)
- **Секреты:** любой подтверждённый (не из аллоулиста) секрет → **вклад в FAIL** (всегда
блок; флаг `security_secrets_block`, дефолт `true`).
- **Зависимости:** severity ≥ `security_dep_block_severity` (дефолт `HIGH`) → **вклад в
FAIL** (`deps_blocking`); ниже порога (`MEDIUM`/`LOW`) → **warning** (`deps_warning`,
не блокирует, фиксируется в теле).
- **Severity = UNKNOWN** (OSV/advisory без CVSS — частый случай pip-audit): трактуется как
**ниже порога → warning**, никогда не авто-блок (анти-петля). Логируется.
### Р-5. Артефакт и вердикт (FR-3, BR-6, канон проекта)
- Новый артефакт **`17-security-report.md`** (следующий свободный номер; финализировано).
- YAML-frontmatter:
```
---
security_status: PASS # PASS | FAIL
secrets_found: 0
deps_blocking: 0
deps_warning: 2
deps_audit_degraded: false
---
```
Тело — человекочитаемый список находок (секреты: файл/правило/маскированное совпадение;
CVE: пакет/версия/идентификатор/severity).
- **Единый источник истины:** гейт вычисляет находки → пишет артефакт → **читает вердикт
обратно через `parse_security_status(content)`** (frontmatter-парсер по образцу
`_parse_deploy_status`/`_parse_staging_status`) → возвращает этот вердикт. Так возвращаемый
`(bool, reason)` гарантированно == frontmatter артефакта (канон «машинный вердикт — только
из YAML-frontmatter, никогда из прозы», AC-8). Negative-токен (`FAIL`) авторитетен.
- Битый/отсутствующий frontmatter / нет поля `security_status` → `(False, reason)` —
fail-closed на чтении вердикта (AC-9).
### Р-6. Поведение красного гейта (FR-4, BR-5)
`security_status: FAIL` → врезка `_handle_security_gate` (по образцу
`_handle_image_freshness`, но БЕЗ работы с lease — гейт до его захвата):
- `update_task_stage(development)` + `enqueue_job("developer", …)`;
- retry-счётчик — **существующий** `_developer_retry_count` (общий с merge/freshness;
без новой колонки, TRZ §6); cap `MAX_DEVELOPER_RETRIES = 3` → при исчерпании
`set_issue_blocked` + Telegram;
- `task_desc` несёт **дословную причину** (какие секреты/файлы, какие пакеты/CVE/severity)
по образцу ORCH-046 — не только ссылку на артефакт (AC-12);
- `notify_qg_failure` + Plane-коммент (наблюдаемость BR-11).
PASS → `return False` из обработчика → `advance_stage` идёт к merge-gate (тишина, без шума).
### Р-7. Условный раскат и устойчивость (FR-5/FR-6)
- `check_security_gate(repo, work_item_id, branch)` в `QG_CHECKS`; обёртка делегирует в
`src/security_gate.py` (ленивый импорт во избежание цикла — по образцу
`_check_staging_image_fresh`).
- Условность: `security_gate_enabled=False` → `(True, "security-gate disabled")`;
`security_gate_repos` (CSV) пусто → реально только `is_self_hosting_repo` → прочие репо
`(True, "security-gate N/A for <repo>")` (AC-14/AC-15).
- **never-raise** (двойной guard как `check_branch_mergeable`): любая ошибка (нет бинаря,
таймаут, исключение) → `(False, reason)`, исключение не уходит в `advance_stage` (AC-16).
- Таймаут сканирования `security_scan_timeout_s` (дефолт 300) на каждый внешний вызов
(`subprocess … timeout=`) — превышение → детерминированный degrade-вердикт (AC-17).
### Р-8. Self-hosting safety (инвариант TRZ §7.5, AC-19)
Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает
деплой-хук, не рестартит и не трогает прод-контейнер (8500/8501).
---
## Точки касания (для developer; reviewer проверяет полноту — AC-20)
| Модуль | Изменение |
|--------|-----------|
| `src/security_gate.py` (**новый leaf**) | `security_gate_applies`, `scan_secrets`, `audit_dependencies`, `classify_severity`, `compute_verdict`, `write_security_report`, `parse_security_status`, `check_security_gate`. never-raise, fail-closed на чтении вердикта. По образцу `image_freshness.py`. |
| `src/qg/checks.py` | `check_security_gate` (тонкая обёртка, ленивый импорт) + регистрация в `QG_CHECKS`. |
| `src/stage_engine.py` | `_handle_security_gate(...)` + врезка ПЕРВОЙ в блоке `current_stage == "deploy-staging"` (до `_handle_merge_gate`). FAIL → откат на `development`. never-raise. **`STAGE_TRANSITIONS` НЕ меняется.** |
| `src/config.py` | `security_gate_enabled` (True), `security_gate_repos` (""), `security_dep_block_severity` ("HIGH"), `security_scan_timeout_s` (300), `security_dep_audit_fail_closed` (False), `security_secrets_block` (True) — с docstring по образцу ORCH-043/058. |
| `Dockerfile` | Установка `gitleaks` (release-бинарь). |
| `requirements.txt` | `pip-audit`. |
| `.gitleaks.toml` (**новый, корень репо**) | Конфиг правил + аллоулист (`.env.example`-плейсхолдеры, тест-фикстуры) — BR-13. |
| `.openclaw/agents/developer.md` | (Опц.) краткая инструкция про устранение security-находок при заворотах. |
| `tests/` | `test_security_gate.py`, `test_qg_security.py`, `test_stage_engine_security_gate.py` (см. `04-test-plan.yaml`). |
| **Документация** | `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md` (таблица гейтов + реестр QG + раздел), `CHANGELOG.md`, `.env.example` (`ORCH_SECURITY_*`), global `adr-0012`. |
---
## Альтернативы (отклонены)
- **Вариант R (review-стадия):** раньше/дешевле, но diff может разойтись с тем, что
вольётся в `main`; merge-edge уже закрывает «последнюю страховку».
- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо
выражаются статусом коммита; коуплинг с CI-раннером. → точка расширения BR-14.
- **fail-closed dep-audit по умолчанию:** ложные откаты при сетевых сбоях → петля. →
только опционально через флаг.
- **Аудит после rebase (как анкер image-freshness):** обвиняет задачу в CVE из `main` →
петля. → скан ветки ДО merge-gate.
- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как
отклонено в ORCH-043). → под-гейт ребра.
- **Новая колонка retry в БД:** не нужна — переиспользуем `_developer_retry_count`.
---
## Последствия
**Плюсы.** Структурно невозможно тихо влить секрет (безусловно) или известную CVE
(best-effort) в `main`/прод автономной системы. Самоприменение CLAUDE.md §8. Минимальный
blast-radius: `STAGE_TRANSITIONS`/схема БД не меняются, переиспользован готовый паттерн.
**Минусы / плата.** Ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`).
Добавлены внешние инструменты (gitleaks-бинарь в образ, pip-audit в зависимости). Время
сканирования добавляется к каждому прогону (ограничено таймаутом). Dep-audit best-effort
при сетевых сбоях (осознанный компромисс против петли). v1 — Python-only (A3); мульти-стек
и SAST — follow-up WI (BR-14).
**Раскат.** Сквозное изменение конвейера (новый QG + новый edge-под-гейт) → лейбл
`arch:major-change`. Прод-деплой ORCH-022 — строго через staging-гейт (8501), без рестарта
прод-контейнера в рамках задачи (self-hosting safety).
## Связи
adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness —
условность/never-raise/fail-closed), adr-0003 (`is_self_hosting_repo` — образец условности),
adr-0009/ORCH-061 (анти-петля ложных FAIL), ORCH-046 (дословный reason в `task_desc`),
ORCH-9/15 (мульти-стек — будущая зависимость).

View File

@@ -0,0 +1,56 @@
# 07 — Инфраструктурные требования: Security-гейт (ORCH-022)
См. `06-adr/ADR-001-security-gate.md` (Р-2, Р-3, Р-8). Топология не меняется (один сервер
mva154, Docker Compose). Новые требования — только инструменты сканирования и сетевой доступ
к CVE-фиду.
## I-1. Бинарь `gitleaks` в образе
- **Что:** статический Go-бинарь `gitleaks` (secret-scanning), устанавливается в `Dockerfile`
(НЕ pip-пакет). Зафиксировать версию (pinned release) для детерминизма.
- **Почему в образе, а не на хосте:** гейт исполняется внутри контейнера оркестратора
(`advance_stage`); сканируется per-task worktree, смонтированный в контейнер.
- **Оффлайн:** gitleaks не требует сети (правила локальны) → гарантия «секрет всегда
блокирует» (BR-2) не зависит от доступности интернета.
- **Контракт exit-кодов:** 0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента
(≥2 → never-raise degrade-вердикт гейта).
## I-2. `pip-audit` в зависимостях
- **Что:** Python-пакет `pip-audit` (dependency audit), добавляется в `requirements.txt`
(pinned-версия).
- **Источник advisory:** OSV / PyPI advisory DB — **требует сетевого доступа** (исходящий
HTTPS к OSV/PyPI).
- **Цель v1:** аудит `requirements.txt` корня репо (Python-стек, A3). Мульти-стек — follow-up.
## I-3. Сетевой доступ к CVE-фиду (degrade-политика)
- **Требование:** исходящий HTTPS из прод-контейнера к OSV/PyPI advisory.
- **При недоступности (Р-3):** **fail-open + громкий warning** по умолчанию — dep-audit не
краснит гейт из-за сетевого сбоя (анти-петля ORCH-061); фиксируется
`deps_audit_degraded: true` + Telegram + лог. Флаг `security_dep_audit_fail_closed`
(дефолт `false`) — для перевода в строгий режим без редеплоя кода.
- **Секреты не зависят от сети** (I-1) — критическая гарантия безусловна.
## I-4. Конфиг-файлы в репозитории (версионируемые, BR-13)
- `.gitleaks.toml` (корень репо): правила + аллоулист заведомо-безопасных совпадений
(плейсхолдеры `.env.example`, тест-фикстуры). Версионируется, ревьюится как код.
## I-5. Env-флаги (`.env.example` + хост `.env`/`.env.staging`)
| Переменная | Дефолт | Назначение |
|------------|--------|-----------|
| `ORCH_SECURITY_GATE_ENABLED` | `true` | глобальный kill-switch |
| `ORCH_SECURITY_GATE_REPOS` | `` (пусто) | CSV scope; пусто → только self-hosting |
| `ORCH_SECURITY_DEP_BLOCK_SEVERITY` | `HIGH` | порог блокировки зависимостей |
| `ORCH_SECURITY_SCAN_TIMEOUT_S` | `300` | таймаут каждого внешнего вызова сканера |
| `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` | `false` | строгий режим при недоступном фиде |
| `ORCH_SECURITY_SECRETS_BLOCK` | `true` | секреты блокируют (всегда по дефолту) |
Секреты-значения в гит НЕ коммитятся (CLAUDE.md §8) — только дефолты в `.env.example`.
## I-6. Ресурсы и тайминги
- Время сканирования добавляется к каждому прогону задачи на ребре `deploy-staging → deploy`,
ограничено `ORCH_SECURITY_SCAN_TIMEOUT_S` (по образцу `merge_retest_timeout_s`).
- Гейт исполняется ДО merge-gate/image-freshness (дёшево фейлить до дорогих rebase/rebuild).
## I-7. Self-hosting safety (инвариант)
Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает
деплой-хук, не рестартит/не трогает прод-контейнер (8500/8501). Прод-деплой ORCH-022 — строго
через staging-гейт (8501).

View File

@@ -0,0 +1,26 @@
# 08 — Требования к схеме БД: Security-гейт (ORCH-022)
## Решение: схема БД НЕ меняется
Миграций нет. Обоснование (соответствует TRZ §6 и паттерну edge-под-гейтов ORCH-043/058):
1. **Вердикт гейта — артефакт-файл** `17-security-report.md` (YAML-frontmatter), как
`14-deploy-log.md` / `15-staging-log.md`. Не хранится в БД.
2. **Состояние/идемпотентность** — детерминированная пересборка вердикта при каждом тике
(гейт чистый, без долгоживущего состояния между прогонами); sentinel-файлы НЕ требуются
(в отличие от deploy-state/post-deploy-state — там асинхронный self-restart).
3. **Retry-счётчик** — переиспользуется существующий `_developer_retry_count(task_id)`
(подсчёт по `jobs`/`agent_runs`), общий с merge-gate/image-freshness. **Новой колонки
`security_retry` НЕ вводим** (TRZ §6: предпочесть подсчёт по `jobs`/`agent_runs`). Это
корректно: security-FAIL, как merge/freshness-FAIL, откатывает на `development` и
запускает developer — он и есть единица retry; общий cap=3 защищает от петли.
## Используемые существующие таблицы (без изменений)
- `tasks` — стадия задачи (`update_task_stage` при откате на `development`).
- `jobs` — enqueue `developer` при FAIL; основа `_developer_retry_count`.
- `agent_runs` — usage/duration; основа подсчёта retry.
## Что НЕ делаем
- Не добавляем таблицу findings/CVE-журнала (история находок — в артефактах per-task; петля
уроков ORCH-8 читает артефакт).
- Не добавляем колонок в `tasks`/`jobs`.

View File

@@ -0,0 +1,16 @@
# 10 — Технические риски: Security-гейт (ORCH-022)
| ID | Риск | Вероятность / Влияние | Митигация (заложена в ADR-001) |
|----|------|----------------------|-------------------------------|
| R-1 | **Ложные срабатывания → петля отката** `→ development` (прецедент ORCH-061 staging-loop). | Средн. / Выс. | Аллоулист `.gitleaks.toml` (BR-13); cap `MAX_DEVELOPER_RETRIES=3` → эскалация (`set_issue_blocked`+Telegram); конфигурируемый порог severity; kill-switch; UNKNOWN-severity → warning, не блок. |
| R-2 | **Недоступность CVE-фида** даёт ложный красный/исключение. | Средн. / Выс. | fail-open + громкий warning по умолчанию (Р-3); `deps_audit_degraded:true`; флаг `security_dep_audit_fail_closed` для строгого режима. Секреты offline → не затронуты. |
| R-3 | **Скан вешает worker-слот** (зависший gitleaks/pip-audit) → стоит конвейер всех проектов (общий инстанс, `max_concurrency`). | Низк. / Выс. | `security_scan_timeout_s` (300) на каждый внешний вызов; never-raise degrade-вердикт; гейт ПЕРВЫМ на ребре (фейлит до дорогих rebase/rebuild). |
| R-4 | **Исключение гейта роняет `advance_stage`** → встаёт движок. | Низк. / Выс. | Двойной never-raise guard (внешний+внутренний) как `check_branch_mergeable`; AC-16/TC-11. |
| R-5 | **Скан после rebase обвиняет задачу в CVE из `main`** → петля. | — (устранён дизайном) | Гейт исполняется ДО merge-gate (скан ветки до rebase); Р-1. |
| R-6 | **Отсутствие бинаря `gitleaks` в образе** (забыт в Dockerfile) → гейт всегда degrade. | Низк. / Средн. | Установка в Dockerfile (I-1), pinned-версия; TC-11 (нет бинаря → `(False,reason)`, never-raise); проверяется на staging (8501) до прода. |
| R-7 | **pip-audit без severity (UNKNOWN)** → либо ложный блок, либо пропуск. | Средн. / Средн. | UNKNOWN → warning (не блок), логируется; осознанный анти-петля компромисс; ужесточение — follow-up. |
| R-8 | **Self-hosting: гейт трогает прод** (рестарт/деплой). | — (запрещено дизайном) | Гейт только читает/сканирует; AC-19/TC-21; прод-деплой ORCH-022 — через staging-гейт. |
| R-9 | **Drift вердикта vs артефакта** (возврат ≠ frontmatter). | Низк. / Средн. | Единый источник: гейт пишет артефакт → читает обратно через `parse_security_status` → возвращает (Р-5); AC-8. |
| R-10 | **Регресс существующих гейтов/стадий** (сломан `QG_CHECKS`/`STAGE_TRANSITIONS`). | Низк. / Выс. | `STAGE_TRANSITIONS` не меняется; новый чек — аддитивно в реестр; полный прогон `tests/` (TC-20); staging-гейт перед прод. |
| R-11 | **v1 Python-only** — секреты/CVE в не-Python стеке (JS/Android) не ловятся. | — (вне scope v1, A3) | Условность scope; точка расширения мульти-стек/SAST (BR-14); зависимость ORCH-9/15 зафиксирована. |
| R-12 | **Стоимость времени** на каждом прогоне задачи. | Низк. / Низк. | Таймаут; гейт первым (ранний выход); только self-hosting по умолчанию. |

View File

@@ -0,0 +1,74 @@
---
type: review
work_item_id: ORCH-022
verdict: APPROVED
version: 1
---
# Review ORCH-022
## Summary
Security-гейт (secret-scanning `gitleaks` + dependency audit `pip-audit`) реализован как
детерминированный под-гейт ребра `deploy-staging → deploy`, исполняемый ПЕРВЫМ среди
edge-под-гейтов — в точности по ADR-001 (Вариант M) и эталонному паттерну соседей
(merge-gate ORCH-043 / image-freshness ORCH-058): leaf-модуль `src/security_gate.py`
(never-raise) + тонкая обёртка `check_security_gate` в `QG_CHECKS` (lazy-import, нет цикла)
+ врезка `_handle_security_gate` ПЕРВОЙ в блоке `current_stage == "deploy-staging"`.
`STAGE_TRANSITIONS` и схема БД не тронуты. Все 772 теста зелёные (25 из них —
security-специфичные: `test_security_gate.py`, `test_qg_security.py`,
`test_stage_engine_security_gate.py`). Документация обновлена полностью и в этом же PR.
### Соответствие ТЗ (02-trz)
- FR-1 secret-scan offline `origin/main..HEAD`, любой секрет вне аллоулиста → FAIL ✓
- FR-2 dep-audit по severity (`HIGH` дефолт), MEDIUM/LOW/UNKNOWN → warning ✓
- FR-3 машинный вердикт ТОЛЬКО из frontmatter `17-security-report.md`, negative-токен
авторитетен, write→read-back (единый источник истины) ✓
- FR-4 FAIL → откат на `development` + developer-retry (cap 3) + `task_desc` с дословными
находками (ORCH-046) ✓
- FR-5 условность `security_gate_enabled` / `security_gate_repos` (пусто → self-hosting) ✓
- FR-6 never-raise + таймаут `security_scan_timeout_s`
- FR-7 наблюдаемость (Telegram при degraded/FAIL, лог при PASS) ✓
- §6 без миграций БД, §7 инварианты соблюдены (STAGE_TRANSITIONS/QG_CHECKS консистентны,
gate не деплоит/не рестартит прод) ✓
### Соответствие ADR (06-adr/ADR-001 + global adr-0012)
Р-1 (размещение ПЕРВЫМ, до merge-gate, до захвата merge-lease → lease не освобождается),
Р-2 (gitleaks pinned Go-бинарь в Dockerfile, pip-audit в requirements), Р-3 (fail-open
degrade + флаг `security_dep_audit_fail_closed`), Р-4 (пороги, UNKNOWN→warning), Р-5
(артефакт + read-back), Р-6 (откат/cap/эскалация), Р-7 (lazy-import, double-guard
never-raise), Р-8 (self-hosting safety) — все реализованы как описано.
### Критерии приёмки (03)
AC-1..AC-21 покрыты тестами TC-01..TC-21 (incl. rollback TC-16, verbatim task_desc TC-17,
cap+blocked TC-18, PASS-advance TC-19, no-deploy-on-FAIL TC-21). AC-20 (документация) —
подтверждён ниже.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- Глобальный `docs/architecture/adr/adr-0012-security-gate.md` помечен `Статус: proposed`,
тогда как per-WI `06-adr/ADR-001``Accepted`. Косметическая рассинхронизация статуса,
на функциональность/гейты не влияет.
## Документация
Обновлена в том же PR (AC-20, CLAUDE.md §6 соблюдён):
- `CLAUDE.md` — раздел «Артефакты задачи» (добавлен `17-security-report.md`) + строка о
машинных вердиктах (`security_status:`).
- `docs/architecture/README.md` — реестр `QG_CHECKS` (`check_security_gate (ORCH-022)`),
новый раздел «Security-гейт …», статусная сноска внизу.
- `docs/architecture/adr/adr-0012-security-gate.md` — новый global ADR (+ per-WI ADR-001).
- `CHANGELOG.md` — подробная запись в `[Unreleased] / Added`.
- `.env.example` — все шесть `ORCH_SECURITY_*` с комментариями.
- `Dockerfile` (pinned gitleaks), `requirements.txt` (pip-audit), `.gitleaks.toml` (корень,
правила + аллоулист) — инфраструктура версионирована.
Статус: документация = golden source — синхронна с кодом. Замечаний нет.

View File

@@ -0,0 +1,76 @@
---
type: test-report
work_item_id: ORCH-022
result: PASS
---
# Test Report — ORCH-022
Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) как под-гейт
ребра `deploy-staging → deploy`.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-07
- Ветка: `feature/ORCH-022-security-secret-scanning`
- Review verdict: APPROVED (`12-review.md`)
## Smoke test API (prod 8500, self-hosting — не трогаем контейнер)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | OK (active task ORCH-022 в stage=testing виден) |
| `GET /queue` | OK (counts/resilience/reconcile/reaper/post_deploy присутствуют) |
## Результаты (привязка к 04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | Секрет в diff → FAIL, secrets_found>=1, причина называет находку | test_security_gate.py::test_tc01_secret_in_diff_fails | PASS |
| TC-02 | Чистая ветка → PASS, secrets_found=0 | test_tc02_clean_branch_passes | PASS |
| TC-03 | Аллоулист подавляет заведомо-безопасное | test_tc03_allowlisted_match_does_not_fail | PASS |
| TC-04 | HIGH/CRITICAL CVE при пороге HIGH → FAIL, deps_blocking>=1 | test_tc04_high_cve_at_high_threshold_blocks | PASS |
| TC-05 | Только MEDIUM/LOW → PASS, deps_warning>=1 | test_tc05_only_medium_low_warns_passes | PASS |
| TC-06 | Конфиг порога severity влияет на классификацию | test_tc06_threshold_config_changes_classification | PASS |
| TC-07 | Недоступный фид → детерминированный degrade (fail-open default / fail-closed strict) | test_tc07_degraded_feed_failopen_default_failclosed_strict | PASS |
| TC-08 | Вердикт ТОЛЬКО из frontmatter; negative-токен авторитетен | test_tc08_verdict_only_from_frontmatter | PASS |
| TC-09 | Нет/битый frontmatter → (False, reason) fail-closed | test_tc09_missing_or_broken_frontmatter_failclosed | PASS |
| TC-10 | Артефакт 17-security-report.md с валидным frontmatter + телом | test_tc10_artifact_has_valid_frontmatter_and_body | PASS |
| TC-11 | Нет бинаря / исключение → (False, reason), never-raise | test_tc11_missing_binary_failclosed_never_raises | PASS |
| TC-12 | Таймаут → детерминированный fail-closed, без зависания | test_tc12_timeout_is_deterministic_failclosed | PASS |
| TC-13 | Не-self репо при пустом scope → (True, N/A) мгновенно | test_qg_security.py::test_tc13_non_self_repo_empty_scope_is_na | PASS |
| TC-14 | ORCH_SECURITY_GATE_ENABLED=false → no-op pass | test_tc14_disabled_is_noop_pass | PASS |
| TC-15 | Зарегистрирован в QG_CHECKS и диспетчеризуется _run_qg | test_tc15_registered_in_qg_checks / test_tc15_dispatched_by_run_qg | PASS |
| TC-16 | FAIL → откат на development, enqueue developer, notify_qg_failure | test_stage_engine_security_gate.py::test_tc16_fail_rolls_back_and_enqueues_developer | PASS |
| TC-17 | task_desc несёт дословную причину (ORCH-046) | test_tc17_task_desc_has_verbatim_findings | PASS |
| TC-18 | После MAX_DEVELOPER_RETRIES (3) → set_issue_blocked + Telegram | test_tc18_retry_cap_blocks_and_alerts | PASS |
| TC-19 | PASS → штатное продвижение конвейера | test_tc19_pass_advances_normally | PASS |
| TC-20 | STAGE_TRANSITIONS не изменён; тесты стадий зелёные | tests/test_stages.py (полный прогон) | PASS |
| TC-21 | Гейт не вызывает деплой-хук/рестарт прод (self-hosting safety) | test_tc21_fail_never_triggers_deploy | PASS |
Все 21 TC покрыты и зелёные. Соответствие критериям приёмки (03-acceptance-criteria):
AC-1..AC-21 закрыты соответствующими TC (AC-N ↔ TC-N для N=1..21; AC-20 «документация»
подтверждён в review 12-review.md).
## Вывод pytest
### Security-специфичные тесты (25 шт.)
```
tests/test_security_gate.py ............... (15)
tests/test_qg_security.py ...... (6)
tests/test_stage_engine_security_gate.py ..... (5)
======================== 25 passed, 1 warning in 0.49s =========================
```
### Полный регресс
```
======================= 772 passed, 1 warning in 14.70s ========================
```
(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-022,
существовал до задачи.)
## Итог
**PASS** — полный регресс 772/772 зелёный, 25 security-тестов покрывают все 21 TC
плана и AC-1..AC-21, smoke-тесты API прод-инстанса OK. Прод-контейнер в процессе
тестирования не затронут (тесты офлайн/изолированы). Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-022
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-022
window_s: 900
checks_total: 30
checks_failed: 0
---
# Post-deploy log — ORCH-021 post-deploy monitor
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.

View File

@@ -0,0 +1,7 @@
# Business Request: [высокий] Статусная модель Plane: осмысленные статусы этапов
Work Item ID: ORCH-066
## Description
TBD

View File

@@ -0,0 +1,110 @@
# 01 — Business Requirements Document (BRD)
**Work Item:** ORCH-066
**Заголовок:** [высокий] Статусная модель Plane: осмысленные статусы этапов
**Стадия:** analysis
**Автор:** Analyst
**Дата:** 2026-06-07
---
## 1. Контекст и проблема
Статусная модель Plane оркестратора имеет **семантические перегрузки**: один и тот
же Plane-статус используется для несовместимых смыслов, из-за чего:
- оператор не понимает, на каком реально этапе стоит задача (доска нечитаема);
- повышается риск ошибки оператора (например, неверный ручной перевод статуса);
- `In Progress` одновременно означает «человек запускает конвейер», «идёт анализ»,
«идёт прод-деплой» и «возврат из Needs Input» — четыре разных смысла на одном статусе.
Уже частично исправлено: ORCH-059 ввёл отдельный статус для подтверждения деплоя
(`Confirm Deploy`), разгрузив перегруженный `Approved`. ORCH-066 завершает наведение
порядка по **утверждённой Owner** статусной модели.
### Два слоя (критично различать)
| Слой | Что это | Источник | Трогаем? |
|------|---------|----------|----------|
| **A** | `STAGE_TRANSITIONS` — внутренняя машина стадий (`created→analysis→…→done`) | `src/stages.py` | **НЕТ (инвариант)** |
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки в `src/stage_engine.py` / `src/webhooks/plane.py` | **ДА** |
ORCH-066 меняет **только слой B** и точки, где код вручную проставляет Plane-статусы.
---
## 2. Целевая статусная модель (решение Owner)
```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
- `[...]` = **действие человека** (вход-триггер).
- Остальное ставит **орк** (индикация).
### Ветки (нелинейные исходы)
- **Rejected** — откат на предыдущую стадию (человек).
- **Needs Input** — ТОЛЬКО аналитик (НЕ расширять на других агентов).
- **Blocked** — затык / фейл деплоя / деградация прода.
- **Cancelled** — человек решил не делать задачу (валидный выход из In Review).
---
## 3. Бизнес-требования
| ID | Требование | Приоритет |
|----|------------|-----------|
| **BR-1** | Каждый этап конвейера показывается на доске Plane осмысленным статусом (To Analyse / Analysis / Code-Review / Awaiting Deploy / Deploying / Monitoring after Deploy). | Must |
| **BR-2** | `To Analyse` — единый человеческий вход: (а) старт нового конвейера, (б) resume/relaunch аналитика при возврате из Needs Input. Заменяет роль `In Progress` как входа-триггера. | Must |
| **BR-3** | Стадия `analysis` индицируется отдельным статусом `Analysis` (орк ставит при старте/relaunch аналитика), а не `In Progress`. | Must |
| **BR-4** | Стадия `review` индицируется Plane-статусом `Code-Review` (переименование `Review`). | Must |
| **BR-5** | Self-deploy Phase A (approval-pending) ставит `Awaiting Deploy` вместо `In Review`. | Must |
| **BR-6** | Self-deploy Phase B (старт прод-деплоя) ставит `Deploying`. | Must |
| **BR-7** | Self-deploy Phase C (health-OK финализация) ставит `Monitoring after Deploy` (НЕ `Done` сразу). | Must |
| **BR-8** | Post-deploy monitor (ORCH-021): чистое закрытие окна (HEALTHY) → `Done`; UNHEALTHY/деградация → `Blocked`. | Must |
| **BR-9** | `In Review` разгрузить: оставить ТОЛЬКО за approve-pending артефактов конвейера (BRD/ревью). Выходы: `Approved` (вперёд), `Rejected` (откат), `Cancelled` (человек отменил). | Must |
| **BR-10** | `Needs Input` — БЕЗ ИЗМЕНЕНИЙ. Остаётся только у аналитика (`01-questions.md``set_issue_needs_input`). Механизм не трогать. | Must |
| **BR-11** | Возврат аналитика из Needs Input выполняется через `To Analyse` (а НЕ через `In Progress`). Логика fork «старт vs resume» (по наличию task + active-job) сохраняется. | Must (грабли R1) |
| **BR-12** | **Fail-closed:** отсутствие нового статуса в проекте (enduro / Plane API down / fallback `_DEFAULT_STATES`) НЕ приводит к падению; поведение остаётся backward-compatible (паттерн ORCH-059 AC-7). | Must |
| **BR-13** | Reconciler не «оживляет» активные ожидания (`Awaiting Deploy` / `Deploying` / `Monitoring after Deploy`) как зависшие задачи (Guard 2 skip-list). | Must |
| **BR-14** | Документация (golden source) обновлена в том же PR: `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md`, ADR per-work-item. | Must |
---
## 4. Границы (Out of Scope / НЕ трогать)
- `STAGE_TRANSITIONS` (`src/stages.py`) — машина стадий, инвариант.
- `QG_CHECKS`, `check_deploy_status`, exit-коды хука (0/1/2), merge-gate, схема БД.
- `Confirm Deploy` (уже работает, ORCH-059).
- Механизм `Needs Input` (analyst-only) — не расширять, не менять.
- Поведение прод-деплоя **не-self** репозиториев (enduro-trails): для них терминальный
переход остаётся `deploy → Done` как сейчас (Monitoring after Deploy не применяется —
post-deploy monitor армится только для self-hosting).
- Автоматический approve / авто-rollback self-hosting (ORCH-54 / ORCH-021 политика
ALERT_ONLY) — не меняется.
---
## 5. Инфра-предусловие (вне кода, делает оператор)
Новые Plane-статусы в проекте **ORCH** создаёт оператор через Plane API **ДО** эксплуатации:
`To Analyse`, `Analysis`, `Code-Review`, `Awaiting Deploy`, `Deploying`,
`Monitoring after Deploy` (`Confirm Deploy` уже есть).
Резолвер (`_PLANE_NAME_TO_KEY` + `get_project_states`) подхватывает их **по имени** с
**fail-closed fallback** на `_DEFAULT_STATES` (см. BR-12). Документируется в
`07-infra-requirements.md` (создаёт архитектор) и в `docs/operations/`.
---
## 6. Definition of Done
- Plane показывает осмысленные статусы на каждом этапе.
- Возврат аналитика из Needs Input работает через `To Analyse`.
- Phase A → `Awaiting Deploy`, Phase B → `Deploying`, Phase C → `Monitoring after Deploy`,
окно HEALTHY → `Done`, фейл → `Blocked`.
- `STAGE_TRANSITIONS` не изменён.
- `pytest tests/ -q` — зелёный. Fail-closed покрыт тестами.
- Документация обновлена.

View File

@@ -0,0 +1,178 @@
# 02 — Техническое задание (ТЗ)
**Work Item:** ORCH-066
**Стадия:** analysis → (вход для architecture)
**Автор:** Analyst
> ТЗ фиксирует ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые точки кода. Конкретную архитектуру
> резолвера (точные имена ключей/функций) финализирует архитектор в ADR. Ниже —
> опорный контракт, согласованный с бизнес-запросом Owner.
---
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче |
|--------|---------------|
| `src/plane_sync.py` | **Ядро изменений (слой B):** реестр логических статусов (`_DEFAULT_STATES`), `_PLANE_NAME_TO_KEY`, маппинг стадия→статус (`_STAGE_TO_STATE_KEY`, `STAGE_VISIBILITY_STATE`), хелперы `set_issue_*`. |
| `src/webhooks/plane.py` | Маршрутизация входящего статуса (`handle_issue_updated`): `To Analyse``handle_status_start` (старт **или** resume). |
| `src/stage_engine.py` | Точки ручной простановки статуса: analyst-flow (`Analysis`/`Needs Input`/`In Review`), Phase A (`Awaiting Deploy`), Phase B (`Deploying`), Phase C → `Monitoring after Deploy`, post-deploy monitor → `Done`/`Blocked`. |
| `src/reconciler.py` | F-2 запрос статусов (`To Analyse` в список), Guard 2 skip-list (активные ожидания). |
| `src/stages.py` | **НЕ менять** (инвариант слоя A). Используется только для чтения переходов. |
| `src/config.py` | (При необходимости) kill-switch для новой статусной модели — на усмотрение архитектора (см. §6). |
---
## 2. Изменения статусной модели (слой B)
### 2.1. Реестр логических статусов (`src/plane_sync.py`)
Ввести новые **логические ключи** и их имена в `_PLANE_NAME_TO_KEY`:
| Логический ключ | Plane name | Назначение |
|-----------------|-----------|------------|
| `to_analyse` | `To Analyse` | Вход-триггер (старт + resume аналитика). |
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review`. |
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |
Сохранить существующие: `backlog`, `todo`, `in_progress` (backward-compat), `needs_input`,
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `testing`,
`approved`, `rejected`. `Cancelled` уже присутствует в `_PLANE_NAME_TO_KEY`.
### 2.2. Fail-closed резолюция (КРИТИЧНО — BR-12)
`get_project_states()` после резолва по API делает `setdefault(k, v)` из `_DEFAULT_STATES`.
Чтобы отсутствие нового статуса в проекте (enduro / Plane down / частичная конфигурация)
**не ломало** конвейер, новые логические ключи в `_DEFAULT_STATES` должны
**алиаситься на существующие UUID** (degrade-to-current):
| Новый ключ | Default-алиас (UUID) | Деградированное поведение |
|------------|----------------------|---------------------------|
| `to_analyse` | = `in_progress` | enduro/старый проект: `In Progress` по-прежнему триггерит старт/resume. |
| `analysis` | = `in_progress` | analysis показывается как `In Progress` (как сейчас). |
| `code_review` | = `review` | review показывается как `Review` (как сейчас). |
| `awaiting_deploy` | = `in_review` | Phase A показывается как `In Review` (как сейчас). |
| `deploying` | = `in_progress` | Phase B показывается как `In Progress` (как сейчас). |
| `monitoring` | = `done` | Phase C показывается как `Done` (как сейчас); монитор затем держит Done / флипает Blocked. |
> Эффект: если оператор НЕ создал новый статус — система работает строго как до ORCH-066
> (никаких падений, никаких 404 от Plane PATCH). Если создал — резолвится по имени и
> используется новый UUID. Это ровно паттерн ORCH-059 AC-7.
### 2.3. Маппинг стадия → статус
`src/plane_sync.py`:
- `_STAGE_TO_STATE_KEY`: `analysis``analysis` (было `in_progress`); `review``code_review`
(было `review`). `deploy` остаётся (управляется Phase A/B/C напрямую, не через
`update_issue_state`). `created`/`architecture`/`development`/`testing`/`done` — без изменений.
- `STAGE_VISIBILITY_STATE`: `review``code_review` (было `review`). Добавить
`analysis``analysis`, если индикация analysis ставится через `set_issue_stage_state`
(решает архитектор; альтернатива — отдельный хелпер `set_issue_analysis`).
- Сохранить совместимость `STAGE_TO_STATE` / `PLANE_STATES` алиасов (импортируются тестами).
### 2.4. Точки простановки статуса
| Место (файл:симв.) | Сейчас | Должно стать |
|--------------------|--------|--------------|
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress``handle_status_start` | `new_state == to_analyse` (с fail-closed: при алиасе совпадает с `in_progress`) → `handle_status_start` |
| `webhooks/plane.py` `start_pipeline` (старт) | статус остаётся `In Progress` | при старте/enqueue analyst орк ставит `Analysis` |
| `webhooks/plane.py` `handle_status_start` (resume из Needs Input) | relaunch на `In Progress`-триггере | relaunch на `To Analyse`-триггере; при relaunch орк ставит `Analysis`. Fork «старт vs resume» (по `get_task_by_plane_id` + `has_active_job_for_task`) — **сохранить как есть.** |
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | оставить `In Review` (BR-9: In Review только за approve-pending конвейера) ✔ без изменений |
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) |
| `stage_engine.py` `_handle_self_deploy_phase_a` | `set_issue_in_review` | `Awaiting Deploy` (`set_issue_awaiting_deploy` или аналог) |
| `stage_engine.py` `_handle_self_deploy_phase_b` | (статус не меняет) | `Deploying` |
| `stage_engine.py` advance `deploy → done` (terminal-sync, строка ~338) | `set_issue_done` для всех | **self-hosting:** `Monitoring after Deploy` (перед/вместо арма монитора); **не-self:** `Done` как сейчас |
| `stage_engine.py` `run_post_deploy_monitor` (HEALTHY, окно закрыто) | пишет лог + коммент, статус Plane НЕ трогает (остаётся Done) | `Done` (явно) |
| `stage_engine.py` `run_post_deploy_monitor` (DEGRADED) | пишет лог + alert | `Blocked` |
> **Замечание по terminal-sync (важно для архитектора):** сейчас `advance_stage` на
> `next_stage == "done"` вызывает `set_issue_done` безусловно (строка ~338), затем армит
> post-deploy monitor для self-hosting (~361). Нужно развести: для репо, где
> `post_deploy.post_deploy_applies(repo)` истинно (self-hosting) — ставить `Monitoring
> after Deploy` вместо `Done`, и переложить простановку `Done`/`Blocked` на финал
> монитора (`run_post_deploy_monitor`). Для прочих репо — `Done` как сейчас.
### 2.5. Новые хелперы `src/plane_sync.py`
Добавить тонкие обёртки по образцу `set_issue_in_review` (резолв per-project UUID +
`_set_issue_state_direct`), never-raise при отсутствии issue:
- `set_issue_analysis(work_item_id, project_id=None)`
- `set_issue_code_review(...)` (или через `set_issue_stage_state("review")`)
- `set_issue_awaiting_deploy(...)`
- `set_issue_deploying(...)`
- `set_issue_monitoring(...)`
(Точный набор/именование — на усмотрение архитектора; контракт: per-project резолв +
fail-closed.)
---
## 3. Изменения reconciler (`src/reconciler.py`)
- **F-2** `_reconcile_plane_project`: добавить `to_analyse` в список запрашиваемых
статусов (`list_issues_by_state([... , to_analyse])`) и в `_reconcile_plane_issue`
маршрутизировать `new_state == to_analyse``handle_status_start` (старт при `task is
None`, resume при существующем task без active-job — логика уже в `handle_status_start`).
Сохранить обработку `approved`/`rejected`. При fail-closed алиасе `to_analyse==in_progress`
поведение не дублируется (один и тот же UUID).
- **Guard 2** `_is_blocked_or_needs_input` (F-1 skip): расширить skip-множество активными
ожиданиями — `awaiting_deploy`, `deploying`, `monitoring` — чтобы реконсилер НЕ
«оживлял» их как зависшие (BR-13). Имя метода/семантику можно обобщить
(«human-or-active-wait»), флаг `reconcile_skip_blocked_enabled` продолжает управлять
этим networked-чеком.
> Примечание: F-1 и так не тронет Phase A (`check_deploy_status` red → silent),
> Deploying (active finalizer job), Monitoring (стадия `done`). Guard 2 — явная
> defense-in-depth по требованию Owner.
---
## 4. Изменения API / эндпоинтов
**Нет** новых HTTP-эндпоинтов. `GET /queue` / `GET /status` — без изменений контракта
(статусы Plane там не отражаются). Изменения только во внешней индикации Plane (PATCH
issue state — существующий механизм).
---
## 5. Изменения схемы БД
**Нет.** `tasks` не хранит Plane-статус (источник истины — стадия в БД + Plane API).
Миграции не требуются.
---
## 6. Требования к новым QG checks
**Нет.** `QG_CHECKS` не расширяется. Статусы — индикация, не управление (канон:
машинные вердикты читаются из YAML-frontmatter артефактов, не из Plane-статуса).
Опционально (на усмотрение архитектора): единый kill-switch новой статусной модели
(env-флаг) для безопасного раската, по образцу `staging_infra_tolerance_enabled` /
`reconcile_skip_blocked_enabled`. Не обязателен, т.к. fail-closed алиасинг (§2.2) уже даёт
backward-compatible деградацию.
---
## 7. Артефакты pipeline, создаваемые/обновляемые
- `06-adr/ADR-001-plane-status-model.md` — архитектор (решение по резолверу,
алиасингу, разводке terminal-sync).
- `07-infra-requirements.md` — архитектор (список Plane-статусов для ручного создания
оператором + Plane API инструкция).
- Документация (golden source, тот же PR): `CLAUDE.md` (секция статусной модели),
`docs/architecture/README.md` (секция статусов рядом с ORCH-036/ORCH-021),
`CHANGELOG.md`.
---
## 8. Инварианты (проверяемые)
- `src/stages.py` `STAGE_TRANSITIONS` — байт-в-байт без изменений.
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate,
схема БД, `Confirm Deploy`, механизм `Needs Input` — без изменений.
- Все новые `set_issue_*` / резолв — never-raise (Plane down ⇒ degrade, не crash).
- Поведение enduro (не-self) и его терминальный `Done` — без регресса.

View File

@@ -0,0 +1,71 @@
# 03 — Критерии приёмки (Acceptance Criteria)
**Work Item:** ORCH-066
Каждый критерий — чёткое условие PASS/FAIL. Покрытие тестами — см. `04-test-plan.yaml`.
---
## Группа A — Вход и стадия анализа
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-1** | `To Analyse` запускает конвейер | Перевод issue без task в `To Analyse``handle_status_start``start_pipeline` (создаётся task, ветка, enqueue analyst). | Не запускается / запускается на другом статусе. |
| **AC-2** | `To Analyse` делает resume аналитика из Needs Input | Существующий task без active-job + перевод в `To Analyse` → relaunch агента текущей стадии (analyst читает свежие комменты). Fork «старт vs resume» определяется по `get_task_by_plane_id` + `has_active_job_for_task` (как раньше). | Создаётся второй task / двойной запуск / resume не происходит. |
| **AC-3** | Стадия `analysis` индицируется статусом `Analysis` | При старте/relaunch аналитика орк ставит `Analysis`. | Остаётся `In Progress` (при наличии статуса `Analysis` в проекте). |
| **AC-4** | Busy-guard сохранён | `To Analyse` при существующем active-job для task → НЕ relaunch (no double launch). | Двойной запуск агента. |
## Группа B — Code-Review
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-5** | Стадия `review` индицируется `Code-Review` | Вход в стадию `review` → Plane-статус `Code-Review`. | Остаётся `Review` (при наличии нового статуса). |
## Группа C — Self-deploy фазы
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-6** | Phase A → `Awaiting Deploy` | `_handle_self_deploy_phase_a` ставит `Awaiting Deploy` (не `In Review`). | Ставит `In Review` (при наличии нового статуса). |
| **AC-7** | Phase B → `Deploying` | `_handle_self_deploy_phase_b` при успешном `initiate_deploy` ставит `Deploying`. | Статус не меняется / иной. |
| **AC-8** | Phase C → `Monitoring after Deploy` (self) | Финализатор SUCCESS для self-hosting → статус `Monitoring after Deploy`, НЕ `Done` сразу. | Ставит `Done` немедленно (для self-hosting). |
| **AC-9** | Не-self deploy → `Done` без регресса | Для не-self репо (`post_deploy_applies==False`) терминальный `deploy → done` ставит `Done` как сейчас. | Не-self репо получает `Monitoring after Deploy` / иной регресс. |
## Группа D — Post-deploy monitor
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-10** | Чистое окно → `Done` | `run_post_deploy_monitor` HEALTHY + окно исчерпано → статус `Done`. | Остаётся `Monitoring after Deploy` / иной. |
| **AC-11** | Деградация → `Blocked` | `run_post_deploy_monitor` DEGRADED → статус `Blocked` (+ существующий ALERT_ONLY для self). | Остаётся в Monitoring / ставит Done. |
| **AC-12** | Self-hosting монитор не рестартит прод | Тик НИКОГДА не рестартит/откатывает прод-контейнер (ORCH-021 BR-5 сохранён). | Тик трогает прод-контейнер. |
## Группа E — In Review / Needs Input / ветки
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-13** | `In Review` только за approve-pending конвейера | `In Review` ставится лишь для approve артефактов (analyst BRD/ревью), не для Phase A. | Phase A / иные стадии ставят `In Review`. |
| **AC-14** | `Needs Input` без изменений | Поведение `set_issue_needs_input` (analyst `01-questions.md`) идентично прежнему; не расширено на других агентов. | Механизм изменён / расширен. |
| **AC-15** | `Cancelled` — валидный выход из In Review без действий конвейера | Перевод в `Cancelled` → орк не выполняет advance/rollback (индикация, не управление). | Орк совершает действие конвейера на `Cancelled`. |
## Группа F — Fail-closed (КРИТИЧНО)
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-16** | Отсутствие нового статуса не ломает конвейер | Проект без новых статусов (enduro/частичный/Plane down) → `get_project_states` отдаёт default-алиасы; все `set_issue_*`/триггеры работают backward-compatible, без исключений и без 404 PATCH. | Падение / необработанное исключение / зависание задачи. |
| **AC-17** | enduro `In Progress` по-прежнему стартует конвейер | Через `to_analyse`-алиас (= `in_progress` UUID) перевод enduro-issue в `In Progress` запускает старт/resume. | enduro-старт сломан. |
| **AC-18** | Резолв по имени | При наличии статуса в проекте по `name` (`_PLANE_NAME_TO_KEY`) используется его UUID, а не default-алиас. | Используется неверный UUID. |
## Группа G — Reconciler
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-19** | F-2 реконсилирует `To Analyse` | `_reconcile_plane_project` запрашивает `to_analyse` и маршрутизирует к `handle_status_start` (старт/resume при потерянном webhook). | `To Analyse`-старты не реконсилируются. |
| **AC-20** | Guard 2 skip активных ожиданий | Задачи в `Awaiting Deploy` / `Deploying` / `Monitoring after Deploy` НЕ «оживляются» F-1 как зависшие. | Реконсилер advance'ит активное ожидание. |
## Группа H — Инварианты и документация
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-21** | `STAGE_TRANSITIONS` не изменён | `src/stages.py` `STAGE_TRANSITIONS` идентичен (diff пуст). | Любое изменение слоя A. |
| **AC-22** | Реестры/контракты не изменены | `QG_CHECKS`, `check_deploy_status`, exit-коды хука, merge-gate, схема БД, `Confirm Deploy` — без изменений. | Любое изменение перечисленного. |
| **AC-23** | Тесты зелёные | `pytest tests/ -q` проходит полностью; новые fail-closed тесты присутствуют и зелёные. | Любой красный тест. |
| **AC-24** | Документация обновлена (golden source) | `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` обновлены; заведён `06-adr/ADR-001-*`. | Любой из артефактов не обновлён. |

View File

@@ -0,0 +1,184 @@
work_item: ORCH-066
description: >
Тест-план статусной модели Plane (слой B). Покрывает осмысленные статусы этапов,
возврат аналитика через To Analyse, фазы self-deploy, post-deploy monitor,
fail-closed деградацию и reconciler. Слой A (STAGE_TRANSITIONS) проверяется на
неизменность. Все тесты — pytest; Plane API мокается (httpx), как в существующих
tests/test_plane_*.py / tests/test_orch10_states.py.
tests:
# --- Группа A: вход и стадия анализа ---
- id: TC-01
type: unit
description: "To Analyse без существующего task -> handle_status_start -> start_pipeline (старт конвейера)."
module: tests/test_status_trigger.py
covers: [AC-1]
expected: PASS
- id: TC-02
type: integration
description: "To Analyse при существующем task без active-job -> relaunch агента стадии (resume из Needs Input), новый task НЕ создаётся."
module: tests/test_plane_to_analyse_resume.py
covers: [AC-2, BR-11]
expected: PASS
- id: TC-03
type: unit
description: "Старт/relaunch аналитика ставит Plane-статус Analysis (а не In Progress) при наличии статуса в проекте."
module: tests/test_plane_status_model.py
covers: [AC-3]
expected: PASS
- id: TC-04
type: unit
description: "To Analyse при существующем task с active-job -> НЕ relaunch (busy-guard)."
module: tests/test_plane_to_analyse_resume.py
covers: [AC-4]
expected: PASS
# --- Группа B: Code-Review ---
- id: TC-05
type: unit
description: "Вход в стадию review -> Plane-статус Code-Review (маппинг _STAGE_TO_STATE_KEY / STAGE_VISIBILITY_STATE)."
module: tests/test_plane_status_model.py
covers: [AC-5]
expected: PASS
# --- Группа C: self-deploy фазы ---
- id: TC-06
type: unit
description: "_handle_self_deploy_phase_a ставит Awaiting Deploy (не In Review)."
module: tests/test_deploy_approve.py
covers: [AC-6, AC-13]
expected: PASS
- id: TC-07
type: unit
description: "_handle_self_deploy_phase_b при успешном initiate_deploy ставит Deploying."
module: tests/test_deploy_approve.py
covers: [AC-7]
expected: PASS
- id: TC-08
type: integration
description: "Phase C (finalizer SUCCESS) для self-hosting ставит Monitoring after Deploy, НЕ Done; армит post-deploy monitor."
module: tests/test_deploy_terminal_sync.py
covers: [AC-8]
expected: PASS
- id: TC-09
type: integration
description: "Не-self репо: deploy->done ставит Done (без регресса, Monitoring не применяется)."
module: tests/test_deploy_terminal_sync.py
covers: [AC-9]
expected: PASS
# --- Группа D: post-deploy monitor ---
- id: TC-10
type: unit
description: "run_post_deploy_monitor HEALTHY + окно исчерпано -> Plane-статус Done."
module: tests/test_post_deploy.py
covers: [AC-10]
expected: PASS
- id: TC-11
type: unit
description: "run_post_deploy_monitor DEGRADED -> Plane-статус Blocked (+ ALERT_ONLY для self)."
module: tests/test_post_deploy.py
covers: [AC-11]
expected: PASS
- id: TC-12
type: unit
description: "Self-hosting тик НЕ рестартит/не откатывает прод-контейнер (ORCH-021 BR-5 сохранён)."
module: tests/test_post_deploy.py
covers: [AC-12]
expected: PASS
# --- Группа E: In Review / Needs Input / Cancelled ---
- id: TC-13
type: unit
description: "In Review ставится только за approve-pending конвейера (analyst BRD ready), не Phase A."
module: tests/test_analyst_status_only_regression.py
covers: [AC-13]
expected: PASS
- id: TC-14
type: unit
description: "set_issue_needs_input (analyst 01-questions.md) поведение идентично прежнему; не расширено на других агентов."
module: tests/test_plane_status_model.py
covers: [AC-14, BR-10]
expected: PASS
- id: TC-15
type: unit
description: "Перевод в Cancelled -> handle_issue_updated не выполняет advance/rollback (индикация, не управление)."
module: tests/test_plane_webhook.py
covers: [AC-15]
expected: PASS
# --- Группа F: fail-closed (критично) ---
- id: TC-16
type: unit
description: "Проект без новых статусов: get_project_states отдаёт default-алиасы (to_analyse=in_progress, code_review=review, awaiting_deploy=in_review, monitoring=done); исключений нет."
module: tests/test_plane_status_failclosed.py
covers: [AC-16, BR-12]
expected: PASS
- id: TC-17
type: unit
description: "Plane API down -> get_project_states fallback на _DEFAULT_STATES; set_issue_* never-raise."
module: tests/test_plane_status_failclosed.py
covers: [AC-16]
expected: PASS
- id: TC-18
type: integration
description: "enduro In Progress по-прежнему стартует конвейер через to_analyse-алиас."
module: tests/test_plane_status_failclosed.py
covers: [AC-17]
expected: PASS
- id: TC-19
type: unit
description: "Резолв по имени: при наличии статуса в проекте используется его UUID, а не default-алиас."
module: tests/test_orch10_states.py
covers: [AC-18]
expected: PASS
# --- Группа G: reconciler ---
- id: TC-20
type: integration
description: "F-2 _reconcile_plane_project запрашивает to_analyse и маршрутизирует к handle_status_start (потерянный webhook старта/resume)."
module: tests/test_reconciler_plane.py
covers: [AC-19]
expected: PASS
- id: TC-21
type: unit
description: "Guard 2: задачи в Awaiting Deploy / Deploying / Monitoring after Deploy НЕ оживляются F-1 как зависшие."
module: tests/test_reconciler.py
covers: [AC-20, BR-13]
expected: PASS
# --- Группа H: инварианты ---
- id: TC-22
type: unit
description: "STAGE_TRANSITIONS не изменён (явная проверка ключей/значений слоя A)."
module: tests/test_plane_status_model.py
covers: [AC-21]
expected: PASS
- id: TC-23
type: unit
description: "QG_CHECKS реестр и check_deploy_status контракты не изменены."
module: tests/test_plane_status_model.py
covers: [AC-22]
expected: PASS
- id: TC-24
type: integration
description: "Полный прогон pytest tests/ -q зелёный (регрессия)."
module: tests/
covers: [AC-23]
expected: PASS

View File

@@ -0,0 +1,287 @@
# ADR-001: Осмысленная статусная модель Plane (слой B)
**Work Item:** ORCH-066
**Стадия:** architecture
**Автор:** Architect
**Дата:** 2026-06-07
**Статус:** Accepted
> Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на
> BRD (`01-brd.md`), ТЗ (`02-trz.md`), критерии приёмки (`03-acceptance-criteria.md`).
> Инфра-предусловие (статусы, создаваемые оператором) — `07-infra-requirements.md`,
> риски — `10-tech-risks.md`.
---
## 1. Контекст
Plane-доска оркестратора семантически перегружена: `In Progress` одновременно
означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат
из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного
перевода статуса. ORCH-059 уже разгрузил `Approved` отдельным `Confirm Deploy`;
ORCH-066 завершает наведение порядка по утверждённой Owner модели.
**Жёсткое разделение двух слоёв (инвариант проекта):**
| Слой | Что | Источник | ORCH-066 |
|------|-----|----------|----------|
| **A** | `STAGE_TRANSITIONS` — машина стадий | `src/stages.py` | **НЕ трогаем** |
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки простановки | **меняем только это** |
Статус — **индикация, не управление**. Машинные вердикты по-прежнему читаются только
из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена
Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих
триггеров `To Analyse`/`Approved`/`Rejected`, которые и раньше были входами).
Целевая модель Owner:
```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
`[...]` = действие человека (вход-триггер); остальное ставит орк (индикация).
Ветки: **Rejected** (откат), **Needs Input** (только аналитик), **Blocked** (затык/фейл
деплоя/деградация), **Cancelled** (человек отменил задачу).
---
## 2. Решение
### 2.1. Реестр логических статусов (`src/plane_sync.py`)
Вводим 6 новых **логических ключей**. Имена в `_PLANE_NAME_TO_KEY` (резолв по имени из
Plane API):
| Логический ключ | Plane name | Назначение |
|-----------------|-----------|------------|
| `to_analyse` | `To Analyse` | Вход-триггер: старт нового конвейера **и** resume аналитика из Needs Input. |
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review` как видимый статус. |
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |
Существующие ключи сохраняются: `backlog`, `todo`, `in_progress`, `needs_input`,
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `review`,
`testing`, `approved`, `rejected`. `Cancelled` уже присутствует.
### 2.2. Fail-closed резолюция — **project-relative alias-fallback** (КРИТИЧНО, BR-12)
ТЗ §2.2 предложил статические алиасы на enduro-UUID в `_DEFAULT_STATES`. Архитектурное
уточнение: для **частично сконфигурированного** проекта (оператор создал не все новые
статусы) статический enduro-UUID в orchestrator-проекте даст невалидный `state` → PATCH
422/404. Поэтому деградация делается **относительно того же проекта**, а не на чужой
UUID.
**Два уровня fallback в `get_project_states()` (success-path), строго в порядке:**
1. Резолв по имени из Plane API (как сейчас).
2. **Alias-fallback (новый):** для каждого отсутствующего нового ключа — UUID его
**базового ключа из этого же проекта**:
```python
_STATE_ALIAS_FALLBACK = {
"to_analyse": "in_progress",
"analysis": "in_progress",
"code_review": "review",
"awaiting_deploy": "in_review",
"deploying": "in_progress",
"monitoring": "done",
}
# после резолва по имени, ДО _DEFAULT_STATES.setdefault:
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
if new_key not in resolved and resolved.get(base_key):
resolved[new_key] = resolved[base_key]
```
3. `_DEFAULT_STATES.setdefault(...)` (как сейчас) — последний резерв для путей, где
API недоступен целиком (`if not project_id: return _DEFAULT_STATES`, полный провал
запроса). В `_DEFAULT_STATES` новые ключи ТОЖЕ добавляются (= enduro-UUID базового
ключа), чтобы любой caller всегда получал полный словарь и `states[key]` не кидал
`KeyError`.
**Эффект деградации:**
| Сценарий | Поведение |
|----------|-----------|
| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). |
| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → **собственный** базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. |
| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (`In Progress`/`Review`/`Done`). |
| Plane API down целиком | `_DEFAULT_STATES` (enduro-UUID) — без регресса относительно сегодняшнего поведения. |
Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все `set_issue_*` и
`_set_issue_state_direct` остаются **never-raise** (PATCH-исключение логируется, не
пробрасывается) — индикация деградирует, слой A не затрагивается.
### 2.3. Маппинг стадия → статус
- `_STAGE_TO_STATE_KEY` (живой путь `update_issue_state`→`stage_to_state`):
`analysis` → `analysis` (было `in_progress`); `review` → `code_review` (было `review`).
`deploy` остаётся `in_progress` (управляется Phase A/B/C напрямую). Остальные — без
изменений.
- `STAGE_VISIBILITY_STATE`: `review` → `code_review`; добавить `analysis` → `analysis`
(для консистентности; `set_issue_stage_state` сейчас dormant, но карта обновляется).
- `STAGE_TO_STATE` (legacy/test-only) — обновить `analysis`→`_DEFAULT_STATES["analysis"]`,
`review`→`_DEFAULT_STATES["code_review"]`. UUID-значения **байт-в-байт прежние** (это
алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют.
### 2.4. Новые хелперы `src/plane_sync.py`
Тонкие обёртки по образцу `set_issue_in_review` (per-project резолв + `_set_issue_state_direct`,
never-raise):
- `set_issue_analysis(work_item_id, project_id=None)`
- `set_issue_code_review(work_item_id, project_id=None)`
- `set_issue_awaiting_deploy(work_item_id, project_id=None)`
- `set_issue_deploying(work_item_id, project_id=None)`
- `set_issue_monitoring(work_item_id, project_id=None)`
`get_project_states` всегда возвращает полный словарь (см. §2.2), поэтому `[key]` не
кидает `KeyError`.
### 2.5. Точки простановки статуса (разводка)
| Файл:место | Сейчас | Должно стать | AC |
|------------|--------|--------------|----|
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` → `handle_status_start` (при алиасе совпадает с `in_progress`) | AC-1, AC-17 |
| `webhooks/plane.py` `start_pipeline` (успешный старт) | статус остаётся `In Progress` | в конце старта орк ставит `set_issue_analysis` | AC-3 |
| `webhooks/plane.py` `handle_status_start` (resume-ветка) | relaunch агента стадии | при relaunch орк ставит `set_issue_analysis`; fork «старт vs resume» (`get_task_by_plane_id` + `has_active_job_for_task`) — **без изменений** | AC-2, AC-4 |
| `webhooks/plane.py` `_rollback_stage` (reject@analysis, ~583) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | **без изменений** (BR-9) | AC-13 |
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | AC-14 |
| `stage_engine.py` rollback@analysis (architect conflict, ~669) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
| `stage_engine.py` `_handle_self_deploy_phase_a` (~1012) | `set_issue_in_review` | `set_issue_awaiting_deploy` | AC-6, AC-13 |
| `stage_engine.py` `_handle_self_deploy_phase_b` (после `INITIATED` marker) | статус не меняет | `set_issue_deploying` | AC-7 |
| `stage_engine.py` terminal-sync `deploy → done` (~338) | `set_issue_done` для всех | **self (`post_deploy_applies`):** `set_issue_monitoring`; **не-self:** `set_issue_done` как сейчас | AC-8, AC-9 |
| `stage_engine.py` `run_post_deploy_monitor` HEALTHY+окно закрыто (~1260) | статус не трогает | `set_issue_done` (явно) | AC-10 |
| `stage_engine.py` `run_post_deploy_monitor` DEGRADED (~1273) | alert/log | `set_issue_blocked` (+ существующий ALERT_ONLY) | AC-11 |
**Разводка terminal-sync (детально, AC-8/AC-9).** Текущий код безусловно зовёт
`set_issue_done` на `next_stage == "done"`, затем (для self) армит post-deploy monitor.
Разводим по `post_deploy.post_deploy_applies(repo)`:
```python
if next_stage == "done" and work_item_id:
if post_deploy.post_deploy_applies(repo):
set_issue_monitoring(work_item_id) # self: окно наблюдения, НЕ Done сразу
else:
set_issue_done(work_item_id) # не-self: терминальный Done как сейчас
# арм монитора (существующий блок ~361) — без изменений
```
Финальный `Done`/`Blocked` для self-hosting перекладывается на `run_post_deploy_monitor`.
При деградированном алиасе `monitoring==done` self-hosting показывает `Done` и затем
монитор держит `Done`/флипает `Blocked` — поведение идентично сегодняшнему.
**AC-12 (инвариант ORCH-021):** добавление `set_issue_blocked` в DEGRADED-ветку —
**только индикация**; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер
(self-hosting остаётся `ALERT_ONLY`). `set_issue_blocked` — Plane-PATCH, не действие над
контейнером.
**Cancelled (AC-15):** изменений кода НЕ требует. `handle_issue_updated` реагирует только
на `to_analyse`/`approved`/`rejected`; `Cancelled` падает в `else` → «no pipeline action».
Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим
кодом.
### 2.6. Reconciler (`src/reconciler.py`)
- **F-2 `_reconcile_plane_project`:** заменить триггер `in_progress` → `to_analyse` в
списке запрашиваемых статусов (`list_issues_by_state([to_analyse, approved, rejected])`)
и в `_reconcile_plane_issue` маршрутизировать `new_state == to_analyse` →
`handle_status_start`. При алиасе `to_analyse == in_progress` (enduro) поведение
идентично текущему (один UUID; `list_issues_by_state` дедуплицирует через `set`). AC-19.
- **Guard 2 `_is_blocked_or_needs_input`:** расширить skip-множество активными ожиданиями
`awaiting_deploy`/`deploying`/`monitoring` (BR-13, AC-20). **Анти-регресс enduro
(КРИТИЧНО):** новые ключи алиасятся на `in_review`/`in_progress`/`done`; добавить их в
skip «как есть» → на enduro `In Progress`/`Done`-задачи начнут ошибочно пропускаться
F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip **только когда
они РАЗЛИЧНЫ от базовых рабочих статусов проекта** (т.е. реально созданы):
```python
base_working = {states.get(k) for k in (
"backlog","todo","in_progress","in_review","review",
"architecture","development","testing","approved","rejected","done")}
extra_waits = {states.get("awaiting_deploy"),
states.get("deploying"),
states.get("monitoring")} - base_working - {None}
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
return cur in skip_set
```
Enduro (алиасы схлопываются в base) → `extra_waits == {}` → нулевой регресс. Orchestrator
(отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до
«human-or-active-wait»; флаг `reconcile_skip_blocked_enabled` продолжает гасить этот
networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A: `check_deploy_status`
red → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадия
`done` → не итерируется) — Guard 2 это defense-in-depth по требованию Owner.
### 2.7. Без kill-switch
Отдельный env-флаг новой модели **не вводится**. Раскат естественно гейтится
**инфра-предусловием**: пока оператор не создал новые статусы — alias-fallback (§2.2)
держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это
проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает
флаг как опциональный — сознательно отказываемся.)
---
## 3. Затронутые модули (карта изменений)
| Модуль | Изменение |
|--------|-----------|
| `src/plane_sync.py` | `_PLANE_NAME_TO_KEY` +6; `_DEFAULT_STATES` +6 (enduro-alias UUID); `_STATE_ALIAS_FALLBACK` (новое) + применение в `get_project_states`; `_STAGE_TO_STATE_KEY` (analysis/review); `STAGE_VISIBILITY_STATE`; `STAGE_TO_STATE` (legacy); 5 новых `set_issue_*`. |
| `src/webhooks/plane.py` | триггер `in_progress`→`to_analyse` в `handle_issue_updated`; `set_issue_analysis` в `start_pipeline` и resume-ветке `handle_status_start`; `_rollback_stage` reject@analysis → `set_issue_analysis`. |
| `src/stage_engine.py` | Phase A → `set_issue_awaiting_deploy`; Phase B → `set_issue_deploying`; terminal-sync split (`monitoring` vs `done`); post-deploy monitor HEALTHY→`set_issue_done`, DEGRADED→`set_issue_blocked`; rollback@analysis (architect conflict) `set_issue_in_progress`→`set_issue_analysis`. |
| `src/reconciler.py` | F-2 триггер `to_analyse`; Guard 2 skip-set + анти-регресс subtraction. |
| `src/stages.py` | **НЕ трогаем** (инвариант слоя A). |
| `src/config.py` | Без изменений (kill-switch не вводится). |
---
## 4. Инварианты (проверяемые, AC-21/AC-22)
- `src/stages.py` `STAGE_TRANSITIONS` — diff пуст (байт-в-байт).
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука (0/1/2),
merge-gate, `check_branch_mergeable`/`check_staging_image_fresh`, схема БД — без изменений.
- `Confirm Deploy` (ORCH-059), механизм `Needs Input` (analyst-only) — без изменений.
- Новых HTTP-эндпоинтов нет; `GET /queue`/`GET /status` контракт без изменений.
- Миграций БД нет (`tasks` не хранит Plane-статус; источник истины — стадия в БД + Plane API).
- Все новые `set_issue_*` / резолв — never-raise.
- Не-self (enduro) терминальный `deploy → Done` — без регресса.
---
## 5. Последствия
**Плюсы**
- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены
от индикации.
- `In Progress` разгружен: больше не «всё подряд».
- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию,
ни конвейер.
- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting).
- Нет нового флага/таблицы → меньше движущихся частей.
**Минусы / ограничения**
- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до
этого orchestrator деградирует до старой индикации (см. `07-infra-requirements.md`).
- Статусы кэшируются per-process (`_STATES_CACHE`): после создания статусов нужен
`reload_project_states()` или рестарт **staging** (не прод — см. self-hosting риск).
- Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой
extra; orchestrator → три статуса).
**Self-hosting (⚠️):** изменения — слой B (Plane-индикация) + reconciler-гварды; машина
стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через `deploy-staging` (8501)
до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта.
---
## 6. Альтернативы (отклонены)
- **Статический enduro-UUID алиас (ТЗ §2.2 буквально):** ломается на частичной
конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative
alias-fallback (§2.2).
- **Глобальный env kill-switch новой модели:** избыточен — инфра-предусловие уже даёт
естественный гейт раската (§2.7).
- **Хранить Plane-статус в `tasks` (миграция БД):** не нужно; источник истины — стадия +
живой Plane API. Нарушило бы инвариант «без лишних зависимостей».
- **Менять `STAGE_TRANSITIONS` ради новых статусов:** запрещено (инвариант слоя A);
статусы — индикация, отделены от машины стадий.

View File

@@ -0,0 +1,96 @@
# 07 — Требования к инфраструктуре
**Work Item:** ORCH-066
**Автор:** Architect
**Дата:** 2026-06-07
> ORCH-066 не меняет топологию (контейнеры/порты/сеть — без изменений, см.
> `docs/operations/INFRA.md`). Единственное инфра-действие — создание новых
> Plane-статусов в проекте **ORCH** руками оператора через Plane API. Это
> **предусловие эксплуатации**, не часть кодового PR.
---
## 1. Что нужно сделать оператору (ДО эксплуатации новой модели)
Создать в Plane-проекте **ORCH** следующие статусы (states) с точными именами —
резолвер сопоставляет их по `name` (`_PLANE_NAME_TO_KEY`):
| Plane name (точно) | Логический ключ | Группа Plane (рекомендуемая) | Назначение |
|--------------------|-----------------|------------------------------|------------|
| `To Analyse` | `to_analyse` | unstarted / started | Человеческий вход: старт конвейера + resume аналитика из Needs Input. |
| `Analysis` | `analysis` | started | Индикация стадии анализа. |
| `Code-Review` | `code_review` | started | Индикация стадии review. |
| `Awaiting Deploy` | `awaiting_deploy` | started | Phase A: ожидание ручного approve на прод-деплой. |
| `Deploying` | `deploying` | started | Phase B: идёт прод-деплой. |
| `Monitoring after Deploy` | `monitoring` | started | Phase C / окно пост-деплой наблюдения. |
`Confirm Deploy` (ORCH-059) и базовые статусы (`Backlog`, `Todo`, `In Progress`,
`Architecture`, `Development`, `Review`, `Testing`, `Approved`, `Rejected`, `Done`,
`Cancelled`, `Needs Input`, `In Review`, `Blocked`) уже существуют — **не трогать**.
> ⚠️ **Точность имён критична.** Резолв идёт по строковому `name`. Опечатка/иной регистр
> → статус не сопоставится → ключ деградирует на собственный базовый UUID проекта
> (alias-fallback, ADR §2.2): индикация откатится к старому статусу, но конвейер
> продолжит работать. Дефис в `Code-Review` — обязателен.
---
## 2. Plane API — как создать статус
Эндпоинт (как в `src/plane_sync.py`, `PLANE_BASE = {plane_api_url}/api/v1`):
```
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/
Headers: X-API-Key: <PLANE_API_TOKEN> (или соответствующий бот-токен с правами)
Body (JSON):
{ "name": "To Analyse", "group": "started", "color": "#3f76ff" }
```
Повторить для каждого имени из таблицы §1. `group` влияет только на колонку доски;
оркестратор `group` не читает (резолв строго по `name`). `color` — на вкус оператора.
Проверка после создания:
```
GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/
```
В ответе должны присутствовать все 6 имён.
---
## 3. Сброс кэша статусов (важно)
`get_project_states` кэширует резолв per-process (`_STATES_CACHE`). После создания
статусов оркестратор подхватит их **только** после сброса кэша:
- штатно — `plane_sync.reload_project_states(project_id)` (или рестарт процесса);
- на **staging** (8501) — безопасный рестарт песочницы;
- на **прод** (8500) — **НЕ рестартить контейнер ради этого** в рамках задачи
(self-hosting: общий контейнер всех проектов). Кэш заполняется при первом обращении к
проекту; если статусы созданы ДО первого PATCH в цикле новой версии — отдельный сброс не
нужен. Если созданы позже — дождаться штатного цикла обновления/деплоя орка.
---
## 4. Порядок раската (рекомендация)
1. Слить кодовый PR ORCH-066 через `deploy-staging` (8501).
2. Создать 6 статусов в проекте ORCH (§1§2).
3. Сбросить кэш / поднять staging, прогнать sandbox-задачу — убедиться, что доска
показывает `Analysis` / `Code-Review` / `Awaiting Deploy` / `Deploying` /
`Monitoring after Deploy` / `Done` на соответствующих этапах.
4. Прод-деплой орка штатным self-deploy (Phase A → approve → Phase B/C).
**До шага 2** система работает строго как до ORCH-066 (alias-fallback) — раскат
безопасно обратим: не создавать/удалить статусы = откат индикации к старой модели,
без изменения кода.
---
## 5. Что НЕ требуется
- Никаких изменений docker-compose, портов, сети, томов, `.env`/`.env.staging`.
- Никаких миграций БД (`tasks` не хранит Plane-статус).
- Никаких изменений в проекте **enduro-trails** — там новые статусы не создаются;
alias-fallback держит прежнюю индикацию (`In Progress`/`Review`/`Done`).

View File

@@ -0,0 +1,31 @@
# 10 — Технические риски
**Work Item:** ORCH-066
**Автор:** Architect
**Дата:** 2026-06-07
Риски слоя B (Plane-индикация). Слой A (`STAGE_TRANSITIONS`/гейты) не затрагивается, поэтому
класс «сломали конвейер» структурно исключён — худший исход любого риска ниже = неверная
**индикация**, не остановка конвейера.
| ID | Риск | Вероятность | Влияние | Митигация |
|----|------|-------------|---------|-----------|
| **R1** | Частичная конфигурация: оператор создал не все 6 статусов в ORCH → отсутствующий ключ деградирует. Наивный статический enduro-UUID дал бы невалидный `state` (PATCH 422) на orchestrator-issue. | Средняя | Средн. | **Project-relative alias-fallback** (ADR §2.2): отсутствующий ключ → собственный базовый UUID проекта → PATCH валиден, индикация откатывается к текущему статусу. Покрыть тестом partial-config. |
| **R2** | Enduro-регресс через Guard 2: новые ключи алиасятся на `in_progress`/`in_review`/`done`; наивное добавление в skip-set заставит F-1 пропускать enduro `In Progress`/`Done` → сломанная реконсиляция (ORCH-053/060). | Средняя | Высок. | **Subtraction базовых рабочих статусов** (ADR §2.6): `extra_waits -= base_working`. На enduro (алиасы схлопнуты) `extra_waits == {}` → нулевой регресс. Тест: enduro-алиас не добавляет skip, orchestrator-distinct добавляет. |
| **R3** | Двойной триггер старта: F-2 reconciler и webhook оба маршрутизируют `to_analyse`; при алиасе `to_analyse == in_progress` возможен повтор. | Низкая | Низк. | `list_issues_by_state` дедуплицирует UUID через `set`; active-job guard + atomic create-claim в `handle_status_start` (`get_task_by_plane_id` + `has_active_job_for_task`) — без двойного старта (AC-4). Сохранить fork как есть. |
| **R4** | Кэш статусов: после создания статусов `_STATES_CACHE` отдаёт старый резолв до сброса → доска не обновляется. | Средняя | Низк. | `reload_project_states()` / рестарт **staging**. Документировано в `07-infra-requirements.md §3`. Прод-рестарт ради кэша — запрещён (self-hosting). |
| **R5** | Опечатка в имени статуса оператором (`Code Review` без дефиса и т.п.) → ключ не резолвится. | Средняя | Низк. | Резолв по точному `name`; при промахе — alias-fallback (деградация, не падение). Точные имена и проверка в `07-infra-requirements.md §12`. |
| **R6** | Terminal-sync split: ошибка ветвления `post_deploy_applies` → enduro получает `Monitoring after Deploy` вместо `Done` (регресс AC-9) или self уходит в `Done` минуя окно (AC-8). | Низкая | Средн. | Единый источник условности — `post_deploy.post_deploy_applies(repo)` (та же функция, что армит монитор). Тесты AC-8 (self→monitoring) и AC-9 (не-self→done). |
| **R7** | Phase B: `set_issue_deploying` поставлен до фактического старта детача → ложная индикация при провале `initiate_deploy`. | Низкая | Низк. | Ставить `set_issue_deploying` **после** успешного `initiate_deploy` и записи `INITIATED` marker (ADR §2.5); провал `initiate_deploy` оставляет `Awaiting Deploy` + просьбу повторить approve. |
| **R8** | Post-deploy DEGRADED → `set_issue_blocked` ошибочно трактуется как «действие над продом». | Низкая | Высок.(если) | `set_issue_blocked` — только Plane-PATCH. Тик остаётся `ALERT_ONLY`, НИКОГДА не рестартит/откатывает прод-контейнер (AC-12, ORCH-021 BR-5). Явный тест: self DEGRADED не трогает контейнер. |
| **R9** | Plane API недоступен в момент простановки статуса → PATCH падает. | Низкая | Низк. | Все `set_issue_*`/`_set_issue_state_direct` — never-raise (логируют, не пробрасывают). Индикация пропускается, слой A не затронут. |
| **R10** | Регресс на тестах, читающих `STAGE_TO_STATE`/`PLANE_STATES` конкретные UUID. | Низкая | Низк. | Новые ключи в `_DEFAULT_STATES` = алиасы на те же in_progress/review/done UUID → значения байт-в-байт; `STAGE_TO_STATE` analysis/review остаются прежними UUID (ADR §2.3). |
| **R11** | Self-hosting: выкладка орка минуя staging. | Низкая | Высок. | Обязательный `deploy-staging` гейт (8501); прод не рестартить вне штатного self-deploy. Раскат обратим (не создавать статусы = старое поведение). |
## Сводный вывод
Все риски снижаемы в рамках принятой архитектуры; ни один не способен остановить конвейер
(слой A инвариантен). Два ключевых требуют аккуратной реализации и обязательных тестов:
**R1** (project-relative alias-fallback) и **R2** (Guard-2 anti-regress subtraction) —
оба зафиксированы в ADR §2.2 и §2.6 как явные контракты. Эскалации `arch:major-change` не
требуется: изменение локализовано в слое B, без новых компонентов/стадий/QG/миграций.

View File

@@ -0,0 +1,89 @@
---
type: review
work_item_id: ORCH-066
verdict: APPROVED
version: 1
---
# Review ORCH-066
## Summary
Осмысленная статусная модель Plane (слой B — индикация). Реализация затрагивает
строго слой B (`src/plane_sync.py`, точки простановки в `src/stage_engine.py` /
`src/webhooks/plane.py` / `src/reconciler.py`) и **не трогает слой A**
(`src/stages.py::STAGE_TRANSITIONS` — diff пуст). Все 4 оси проверки (ТЗ, ADR,
качество кода, тесты) и проверка документации — пройдены. `pytest tests/ -q`:
**774 passed**. Вердикт — **APPROVED**.
## Соответствие ТЗ (02-trz.md)
- §2.1 — 6 новых логических ключей в `_PLANE_NAME_TO_KEY` + `_DEFAULT_STATES`. ✔
- §2.2 — fail-closed резолюция (BR-12). ✔ (реализована усиленная project-relative
версия — см. ADR ниже).
- §2.3 — `_STAGE_TO_STATE_KEY` (analysis→analysis, review→code_review),
`STAGE_VISIBILITY_STATE`, legacy `STAGE_TO_STATE` (UUID байт-в-байт прежние). ✔
- §2.4 — точки простановки разведены (handle_issue_updated триггер `to_analyse`,
start_pipeline/resume → Analysis, Phase A → Awaiting Deploy, Phase B → Deploying,
terminal-sync split, post-deploy HEALTHY→Done / DEGRADED→Blocked,
rollback@analysis → Analysis). ✔
- §2.5 — 5 новых never-raise хелперов `set_issue_*`. ✔
- §3 — reconciler F-2 триггер `to_analyse` (+ resume-ветка), Guard 2 skip-set с
вычитанием base_working. ✔
- §4/§5/§6 — нет новых эндпоинтов, нет миграций БД, `QG_CHECKS` не расширен. ✔
## Соответствие ADR (06-adr/ADR-001)
- §2.2 project-relative alias-fallback (`_STATE_ALIAS_FALLBACK`, применён ДО
`_DEFAULT_STATES.setdefault`) — реализован точно по контракту, деградация на
собственный базовый UUID проекта, PATCH остаётся валидным на частичной
конфигурации. ✔
- §2.5 terminal-sync split по `post_deploy.post_deploy_applies(repo)` — реализован
как в ADR (self → Monitoring, не-self → Done). ✔
- §2.6 Guard 2 анти-регресс (extra_waits base_working {None}) — реализован
дословно, enduro-алиасы схлопываются → нулевой регресс. ✔
- §2.7 без kill-switch — config.py не изменён (diff пуст). ✔
## Качество кода
- Все новые `set_issue_*` следуют образцу `set_issue_in_review` (per-project резолв
+ `_set_issue_state_direct`), контракт never-raise сохранён, есть docstrings. ✔
- Post-deploy/terminal-sync простановки обёрнуты в try/except с warning-логом
(never break the tick). ✔
- Переменные в scope корректны (`work_item_id` определён до всех новых вызовов в
`start_pipeline`/`handle_status_start`/stage_engine). ✔
- AC-12 соблюдён: `set_issue_blocked` в DEGRADED-ветке — только индикация, тик
прод-контейнер не трогает. ✔
## Качество тестов
- Содержательные, не тривиальные: `test_plane_status_failclosed.py`
(TC-16/17/18 — partial project, API down, never-raise сеттеров, enduro alias
старт), `test_plane_to_analyse_resume.py`, `test_plane_status_model.py`,
`test_deploy_terminal_sync.py` (self/не-self split), `test_post_deploy_integration.py`,
`test_reconciler*.py` (F-2 to_analyse + Guard 2). ✔
## Инварианты (AC-21/AC-22)
- `src/stages.py` — diff 0 строк (STAGE_TRANSITIONS байт-в-байт). ✔
- `src/qg/checks.py` — diff 0 строк (QG_CHECKS, check_deploy_status). ✔
- `src/config.py` — diff 0 строк. ✔
- Схема БД — без миграций. ✔
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
## Документация
Обновлена в том же PR (golden source соблюдён):
- `CLAUDE.md` — добавлена секция «Статусная модель Plane (ORCH-066)». ✔
- `docs/architecture/README.md` — секция «Осмысленная статусная модель Plane
(ORCH-066)» + обновлён статусный footer. ✔
- `CHANGELOG.md` — подробная запись в [Unreleased]/Added. ✔
- `06-adr/ADR-001-plane-status-model.md` — заведён. ✔
- `07-infra-requirements.md` — присутствует (инфра-предусловие: 6 Plane-статусов
создаёт оператор). ✔
Изменения `src/` полностью отражены в документации → требование
«документация обновлена при изменении src/» выполнено.

View File

@@ -0,0 +1,77 @@
---
type: test-report
work_item_id: ORCH-066
result: PASS
---
# Test Report — ORCH-066
Осмысленная статусная модель Plane (слой B — индикация). Прогон полного регресса +
покрытие тест-плана `04-test-plan.yaml` + проверка инвариантов слоя A.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: feature/ORCH-066-plane
- Дата: 2026-06-07
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Покрывает | Описание | Модуль | Результат |
|-------|-----------|----------|--------|-----------|
| TC-01 | AC-1 | To Analyse без task → start_pipeline | test_status_trigger.py | PASS |
| TC-02 | AC-2,BR-11 | To Analyse resume аналитика, без двойного task | test_plane_to_analyse_resume.py | PASS |
| TC-03 | AC-3 | Старт/relaunch → статус Analysis | test_plane_status_model.py | PASS |
| TC-04 | AC-4 | Busy-guard: active-job → не relaunch | test_plane_to_analyse_resume.py | PASS |
| TC-05 | AC-5 | review → статус Code-Review | test_plane_status_model.py | PASS |
| TC-06 | AC-6,AC-13 | Phase A → Awaiting Deploy (не In Review) | test_deploy_approve.py | PASS |
| TC-07 | AC-7 | Phase B → Deploying | test_deploy_approve.py | PASS |
| TC-08 | AC-8 | Phase C self → Monitoring after Deploy | test_deploy_terminal_sync.py | PASS |
| TC-09 | AC-9 | Не-self deploy→done → Done (без регресса) | test_deploy_terminal_sync.py | PASS |
| TC-10 | AC-10 | Post-deploy HEALTHY → Done | test_post_deploy.py | PASS |
| TC-11 | AC-11 | Post-deploy DEGRADED → Blocked | test_post_deploy.py | PASS |
| TC-12 | AC-12 | Self-тик не рестартит прод | test_post_deploy.py | PASS |
| TC-13 | AC-13 | In Review только за approve-pending | test_analyst_status_only_regression.py | PASS |
| TC-14 | AC-14,BR-10 | Needs Input без изменений | test_plane_status_model.py | PASS |
| TC-15 | AC-15 | Cancelled → нет действий конвейера | test_plane_webhook.py | PASS |
| TC-16 | AC-16,BR-12 | Fail-closed default-алиасы, нет исключений | test_plane_status_failclosed.py | PASS |
| TC-17 | AC-16 | Plane API down → fallback, never-raise | test_plane_status_failclosed.py | PASS |
| TC-18 | AC-17 | enduro In Progress стартует через алиас | test_plane_status_failclosed.py | PASS |
| TC-19 | AC-18 | Резолв по имени → корректный UUID | test_orch10_states.py | PASS |
| TC-20 | AC-19 | F-2 реконсилирует To Analyse | test_reconciler_plane.py | PASS |
| TC-21 | AC-20,BR-13 | Guard 2 skip активных ожиданий | test_reconciler.py | PASS |
| TC-22 | AC-21 | STAGE_TRANSITIONS не изменён | test_plane_status_model.py | PASS |
| TC-23 | AC-22 | QG_CHECKS/check_deploy_status не изменены | test_plane_status_model.py | PASS |
| TC-24 | AC-23 | Полный регресс pytest зелёный | tests/ | PASS |
Все 24 тест-кейса — PASS.
## Инварианты слоя A (AC-21 / AC-22)
Diff против `origin/main` (merge-base `4815e378`):
- `src/stages.py` (STAGE_TRANSITIONS) — diff пуст ✔
- `src/qg/checks.py` (QG_CHECKS, check_deploy_status) — diff пуст ✔
- `src/config.py` (без kill-switch) — diff пуст ✔
## Smoke test API (TestClient — прод-контейнер 8500 не трогался)
> `curl` в окружении недоступен; smoke прогнан через FastAPI TestClient (lifespan),
> без рестарта/обращения к прод-контейнеру (self-hosting safety).
| Endpoint | Статус | Тело (фрагмент) |
|----------|--------|-----------------|
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
| GET /status | 200 | `{"active_tasks":[...]}` |
| GET /queue | 200 | `{"counts":{...},"max_concurrency":1,...}` |
## Вывод pytest
```
======================= 774 passed, 1 warning in 17.68s ========================
```
(единственный warning — PydanticDeprecatedSince20 в src/config.py, предсуществующий,
не связан с ORCH-066)
Прогон по модулям тест-плана: `117 passed` (ORCH-066-специфичные файлы).
## Итог
PASS — все тесты зелёные (774 passed), все 24 TC покрыты, инварианты слоя A
сохранены (diff пуст), smoke-эндпоинты отвечают 200. Review-вердикт APPROVED.
Задача готова к переходу на стадию deploy-staging.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-066
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,39 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T22:01:57Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
run canonically via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`.
**Result: 8/10 checks PASS — exit code 0 (advance).**
All REAL (pipeline) checks are green. The two failing checks are the known
SANDBOX_INFRA-only checks C9a/C9b (sandbox branch / analyst-job — depend on
SANDBOX bot accounts being project members, not on the pipeline), which are
waived under ORCH-061 since every REAL check passed.
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
## Check breakdown
| Block | Check | Result |
|-------|-------|--------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 with 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 | FAIL (waived — sandbox-infra) |
| C E2E | C9b Analyst job enqueued in staging queue | FAIL (waived — sandbox-infra) |
CLEANUP completed: test Plane issue deleted (HTTP 204); no branch to delete.

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-066
window_s: 900
checks_total: 30
checks_failed: 0
---
# Post-deploy log — ORCH-021 post-deploy monitor
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.

View File

@@ -0,0 +1,7 @@
# Business Request: [высокий] Telegram tracker: bump + статусы Plane + кликабельный номер задачи
Work Item ID: ORCH-067
## Description
TBD

View File

@@ -0,0 +1,158 @@
# BRD — ORCH-067: Telegram tracker (bump + статусы Plane + кликабельный номер задачи)
Work Item: **ORCH-067**
Тип: **Багфикс + enhancement**
Приоритет: высокий
Компонент: Telegram live-tracker и уведомления оркестратора (`src/notifications.py`)
Расширяет: открытый баг seq=55 («bump не сработал, регресс ORCH-042»)
---
## 1. Бизнес-контекст и проблема
Оркестратор ведёт по одной «живой карточке» (live-tracker) на каждую задачу в Telegram
(`src/notifications.py`). Карточка тихо обновляется на каждом переходе стадии, а отдельными
пингами шлются только события, требующие внимания владельца (approve-gate, деплой-фейл,
падение агента, ошибка задачи).
Сейчас есть четыре боли:
1. **bump не работает в проде.** Диагностика оператора: код режима `bump` в
`update_task_tracker` корректен (delete старого → sendMessage вниз → repoint
`tracker_message_id`), НО в проде `tracker_mode="edit"` (дефолт `src/config.py:408`),
а `ORCH_TRACKER_MODE=bump` не выставлен. Карточка обновляется edit-in-place и остаётся
«вверху» ленты, тонет под новыми сообщениями — наблюдатель не видит актуального
состояния без скролла.
2. **Карточка показывает внутренние названия стадий, а не Plane-статусы.** После ввода
осмысленной статусной модели Plane (ORCH-066) карточка по-прежнему рендерит внутренние
ярлыки стадий (Анализ/Архитектура/…), а текущий статус задачи в терминах, понятных
наблюдателю в Plane (To Analyse → Analysis → In Review → … → Done), в шапке карточки
не отражён. Особенно теряется состояние **ожидания согласования BRD** = Plane-статус
`In Review`: сейчас это лишь строка «✅/⏸️ Подтверждение BRD … ⏳», не выраженная как
полноценный статус.
3. **Номер задачи в карточке некликабелен.** `ORCH-066` в карточке — обычный текст;
чтобы открыть задачу в Plane, наблюдателю приходится искать её вручную.
4. **Номер задачи некликабелен и во всех остальных уведомлениях орка** (approve-requested,
QG-fail, deploy SUCCESS/FAIL, Needs Input, прод-деплой и т. п.) — везде, где упоминается
`work_item_id`, это просто текст.
## 2. Цель
Сделать live-tracker и уведомления орка наблюдаемыми «из коробки»:
- bump работает по умолчанию (карточка падает вниз свежим сообщением при каждом обновлении,
ровно одна карточка на задачу, без спама и дублей);
- карточка явно показывает текущий Plane-статус по модели ORCH-066, включая человеческие
гейты (`⏸️ In Review` — согласование BRD, `⏸️ Awaiting Deploy` — ожидание Confirm Deploy,
`❓ Needs Input` — нужны уточнения);
- номер задачи кликабелен в карточке и во всех Telegram-уведомлениях орка и ведёт на
страницу задачи в Plane.
## 3. Заинтересованные стороны
- **Owner (Слава)** — основной потребитель карточки и уведомлений; источник 4 требований.
- **Агенты конвейера** — косвенно (карточка отражает их прогресс; поведение агентов не меняется).
- **Другие проекты (enduro-trails)** — общий инстанс/БД; изменения не должны вызывать регресс.
## 4. Объём работ (scope)
### 4.1. Требование 1 — bump по умолчанию
- Режим `bump` должен быть поведением по умолчанию: при каждом обновлении карточка
удаляется и пересоздаётся внизу ленты, одна карточка на задачу, тихо
(`disable_notification`), без дублей.
- Инвариант «одна карточка на задачу» сохраняется в обоих режимах (`edit` остаётся как
опция через env).
- Транзиентный фейл `send` не должен обнулять `tracker_message_id` и плодить дубли
(инвариант уже заложен в коде — сохранить).
### 4.2. Требование 2 — статусы карточки как в Plane (модель ORCH-066)
- В шапке/верхней части карточки явно отображается **текущий Plane-статус** задачи по
модели ORCH-066.
- Полный маппинг состояний (имена — финальные из модели ORCH-066):
```
To Analyse → Analysis → In Review (⏸️ ожидание согласования BRD) → Architecture →
Development → Code-Review → Testing → Awaiting Deploy (⏸️ ожидание Confirm Deploy) →
Deploying → Monitoring after Deploy → Done
```
Ветки: `Needs Input` (аналитик задал вопросы), `Blocked`, `Rejected`, `Cancelled`.
- Человеческие гейты отражаются как ПОЛНОЦЕННЫЕ статусы с паузой:
- согласование BRD → «⏸️ In Review — ожидание согласования BRD»;
- ожидание прод-деплоя → «⏸️ Awaiting Deploy — ожидание Confirm Deploy»;
- вопросы аналитика → «❓ Needs Input — нужны уточнения».
- Существующая семантика строки «Подтверждение BRD» сохраняется (время ожидания/«твоё
время»), но статус карточки при этом явно показывает In Review (approve-pending).
### 4.3. Требование 3 — кликабельный номер задачи в карточке
- `work_item_id` (напр. `ORCH-066`) в карточке — гиперссылка на страницу задачи Plane:
`https://<PLANE_WEB_BASE>/<workspace_slug>/projects/<project_id>/issues/<issue_id>/`.
- Источники частей URL:
- `PLANE_WEB_BASE` — из конфигурации (env, поле `plane_web_url` / `ORCH_PLANE_WEB_URL`;
значение прод — `plane.mva154.duckdns.org`); fail-safe: не задан → номер без ссылки;
- `workspace_slug` — `plane_workspace_slug` (уже есть в settings, прод — `ag_proj`);
- `project_id` — резолвится per-task по репозиторию задачи (ORCH / Sandbox);
- `issue_id` (UUID) — из БД: колонка `tasks.plane_issue_id`.
- Рендер через `<a href="...">ORCH-NNN</a>` (`parse_mode=HTML` уже включён);
HTML в title/тексте экранируется, чтобы не сломать разметку.
### 4.4. Требование 4 — кликабельный номер во ВСЕХ уведомлениях орка
- Единый хелпер (напр. `plane_issue_link(work_item_id, plane_issue_id, project_id) -> html`)
строит кликабельный номер с fail-safe; применяется во всех точках `send_telegram`/
`notify_*`, где упоминается `work_item_id` (approve-requested, QG-fail, deploy
SUCCESS/FAIL, Needs Input, прод-деплой, alert'ы launcher/merge_gate/job_reaper/
security_gate/reconciler/main).
## 5. Вне объёма (out of scope)
- Транспорт `send_telegram` / `edit_telegram` / `delete_telegram` (parse_mode HTML уже есть) — не трогать.
- Инвариант «одна карточка на задачу» — не нарушать (не плодить дубли).
- Логика `disable_notification` (карточка тихая; пингуют только alert-хелперы) — не трогать.
- `STAGE_TRANSITIONS`, Quality Gates, схема БД — НЕ менять.
- Изменение поведения агентов/конвейера.
## 6. Зависимости
- Маппинг статусов (требование 2) опирается на статусную модель ORCH-066. ORCH-066 уже в
конвейере на стадии deploy. Эту задачу делать ПОСЛЕ прода ORCH-066, чтобы имена статусов
совпали. Если ORCH-066 ещё не в проде на момент разработки — использовать согласованные
финальные имена из модели: To Analyse, Analysis, Code-Review, Awaiting Deploy, Deploying,
Monitoring after Deploy, In Review, Needs Input, Blocked, Cancelled, Done.
- Конфигурация `plane_web_url` / `plane_workspace_slug` уже существует в `src/config.py`
(ORCH-017); реестр проектов `src/projects.py` (`get_project_by_repo().plane_project_id`)
уже даёт per-task project_id.
## 7. Fail-safe (обязательно)
- Нет `PLANE_WEB_BASE` / нет `plane_issue_id` / нет `project_id` / loopback-база →
показывать номер БЕЗ ссылки, **не падать**.
- HTML-экранирование пользовательского текста (title и пр.) во всех сообщениях с
`parse_mode=HTML`.
- Bump: транзиентный фейл `send` не обнуляет `tracker_message_id` и не плодит дубли.
- Любая ошибка построения статуса/ссылки никогда не должна ронять рендер карточки или
отправку уведомления (degrade gracefully).
## 8. Критерии успеха (Definition of Done)
- Bump работает из коробки: карточка падает вниз при обновлении, одна на задачу.
- Карточка показывает Plane-статус новой модели, включая `⏸️ In Review` (согласование BRD),
`⏸️ Awaiting Deploy`, `❓ Needs Input`.
- Номер задачи кликабелен в карточке И во всех уведомлениях орка (ведёт на страницу Plane).
- Fail-safe покрыт тестами (нет URL/plane_id/project → без ссылки, не падает;
HTML-экранирование).
- `pytest tests/ -q` зелёный.
- Документация обновлена в том же PR: `CLAUDE.md` (раздел нотификаций/tracker),
`CHANGELOG.md`, ADR per-work-item.
## 9. Риски
- **Регресс enduro-trails.** Смена дефолта `tracker_mode` на bump меняет поведение для всех
проектов. Митигация: bump уже реализован и протестирован концептуально; инвариант «одна
карточка» сохранён; env-переключатель `edit` остаётся.
- **Поломка HTML-разметки** при неэкранированном title → сообщение не доставится. Митигация:
обязательное `html.escape` + тесты.
- **Источник «истинного» Plane-статуса** для веток, не выводимых из `tasks.stage`
(Needs Input/Blocked/Rejected/Cancelled, Deploying/Monitoring), при запрете на изменение
схемы БД — архитектурное решение (ADR), с обязательным fail-safe (без сети не падать).
- **Self-hosting.** Орк правит сам себя; обязательна страховка через staging (8501) перед
прод-деплоем; прод-контейнер не ронять в рамках задачи.

View File

@@ -0,0 +1,205 @@
# ТЗ — ORCH-067: Telegram tracker (bump + статусы Plane + кликабельный номер задачи)
Work Item: **ORCH-067**
Документ описывает КОНКРЕТНЫЕ изменения кода/конфигурации/тестов и документации.
Архитектурные развилки помечены `[ARCH]` — решение принимает архитектор (ADR), здесь
зафиксированы только требования и ограничения к ним.
---
## 0. Задействованные модули `src/`
| Модуль | Роль в задаче |
|---|---|
| `src/config.py` | Дефолт `tracker_mode`; поле `plane_web_url`/`plane_workspace_slug` (уже есть). |
| `src/notifications.py` | Основные изменения: bump-дефолт, статус-строка карточки, хелпер ссылки, применение хелпера в `notify_*`. |
| `src/plane_sync.py` | Источник имён статусов/маппинга ORCH-066 (`_PLANE_NAME_TO_KEY`, `_STAGE_TO_STATE_KEY`); при необходимости reverse-map UUID→имя `[ARCH]`. |
| `src/projects.py` | `get_project_by_repo(repo).plane_project_id` — per-task project_id для ссылки. |
| `src/db.py` | Чтение `tasks.plane_issue_id`, `tasks.repo` (без изменений схемы). |
| `src/stage_engine.py`, `src/agents/launcher.py`, `src/merge_gate.py`, `src/job_reaper.py`, `src/security_gate.py`, `src/reconciler.py`, `src/main.py` | Точки `send_telegram`, где есть `work_item_id` — применить хелпер ссылки (требование 4). |
Изменения API (HTTP endpoints) — **нет**. Изменения схемы БД — **нет**. Новые QG checks — **нет**.
---
## 1. Требование 1 — bump по умолчанию
### 1.1. Изменение
- `src/config.py` (~стр. 408): сменить дефолт
`tracker_mode: str = "edit"``tracker_mode: str = "bump"`.
- Обновить docstring-комментарий рядом (ORCH-042): отметить, что **дефолт теперь `bump`**,
`edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`.
### 1.2. Без изменений (сохранить инвариант)
- Логика `update_task_tracker` (`src/notifications.py`, ветка `if mode == "bump"`):
`delete_telegram(old)` best-effort → `send_telegram(text, disable_notification=True)`
`set_tracker_message_id` ТОЛЬКО при `new_mid is not None`. Не менять.
- `send_telegram`/`edit_telegram`/`delete_telegram` — не трогать.
### 1.3. Прод-аспект
- Для прод-инстанса орка можно дополнительно выставить `ORCH_TRACKER_MODE=bump` в `.env`
на хосте (как страховку), но код должен работать «из коробки» и без env. Канон env —
`.env.example` (обновить, если там фигурирует tracker_mode).
---
## 2. Требование 2 — статус-строка карточки по модели ORCH-066
### 2.1. Новый чистый хелпер маппинга
Добавить в `src/notifications.py` функцию, возвращающую отображаемый Plane-статус для
карточки на основе доступных данных задачи. Сигнатура (ориентир):
```python
def plane_status_label(task_row) -> str:
"""Вернуть строку текущего Plane-статуса для шапки карточки (с emoji).
Никогда не падает: на неизвестном входе -> разумный дефолт по stage."""
```
Хелпер обязан быть чистым/детерминированным от входных данных и **никогда не бросать**
исключения (любая ошибка → дефолт по `stage`, рендер карточки не ломается).
### 2.2. Маппинг внутреннее состояние → Plane-статус (обязательные строки)
Имена статусов — финальные из модели ORCH-066 (см. `_PLANE_NAME_TO_KEY` в `plane_sync.py`).
| Источник (данные задачи в БД) | Plane-статус (отображение в карточке) |
|---|---|
| `stage == "created"` | `To Analyse` |
| `stage == "analysis"`, BRD-clock не запущен | `Analysis` |
| `stage == "analysis"`, `brd_review_started_at` есть, `brd_review_ended_at` пуст | `⏸️ In Review — ожидание согласования BRD` |
| `stage == "architecture"` | `Architecture` |
| `stage == "development"` | `Development` |
| `stage == "review"` | `Code-Review` |
| `stage == "testing"` | `Testing` |
| `stage == "deploy"` (ожидание Confirm Deploy) | `⏸️ Awaiting Deploy — ожидание Confirm Deploy` |
| `stage == "done"` | `Done` |
Ветки (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy):
- `❓ Needs Input — нужны уточнения` — состояние «аналитик задал вопросы»;
- `Blocked`, `Rejected`, `Cancelled`, `Deploying`, `Monitoring after Deploy`.
`[ARCH]` **Источник сигнала для веток, не выводимых из `tasks.stage`** (Needs Input,
Blocked, Rejected, Cancelled, Deploying, Monitoring after Deploy):
- запрещено менять схему БД (нельзя добавлять колонку-флаг);
- варианты для архитектора: (а) best-effort чтение живого Plane-статуса
(`fetch_issue_state` + reverse-map UUID→имя через `get_project_states`/
`_PLANE_NAME_TO_KEY`) с обязательным fail-safe (нет сети/ответа → деградация на
stage-маппинг, без задержки, блокирующей конвейер); (б) только stage-выводимые статусы,
а ветки — по уже имеющимся сигналам (например, In Review через brd-clock).
- ОБЯЗАТЕЛЬНО к покрытию (DoD): `⏸️ In Review`, `⏸️ Awaiting Deploy`, `❓ Needs Input`.
In Review полностью выводится из brd-clock (см. таблицу) и должен работать без сети.
### 2.3. Встраивание в `render_task_tracker`
- В `render_task_tracker` (`src/notifications.py`) добавить в шапку/верх карточки отдельную
СТРОКУ статуса (под заголовком `🛠️ ORCH-NNN · <title>` / над разделителем `bar`),
напр.: `📍 <status_label>`.
- Существующие строки по стадиям (`✅ done` / `🔄 active`), строка «Подтверждение BRD»,
тоталы токенов/стоимости, done-строка с PR/⏱️ — СОХРАНИТЬ (семантику не ломать).
- Семантика строки «Подтверждение BRD» (⏸️+⏳ при ожидании, ✅ при пройденном гейте)
сохраняется; новая статус-строка дублирует её смысл в терминах Plane-статуса.
---
## 3. Требование 3 + 4 — кликабельный номер задачи
### 3.1. Единый хелпер
Добавить в `src/notifications.py`:
```python
def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str:
"""Вернуть HTML с кликабельным номером задачи (<a href=...>ORCH-NNN</a>),
либо просто html.escape(work_item_id), если ссылку построить нельзя.
Никогда не падает."""
```
Поведение:
- База URL: `settings.plane_web_url` → fallback `settings.plane_api_url`; loopback-база
(`localhost`/`127.0.0.1`/…) трактуется как «нет web URL» (переиспользовать
`_is_loopback_base`).
- `workspace_slug`: `settings.plane_workspace_slug`.
- `project_id`: явный аргумент → иначе резолв по `repo` через
`get_project_by_repo(repo).plane_project_id`.
- `issue_id`: `plane_issue_id` (UUID из `tasks.plane_issue_id`).
- URL-шаблон: `{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/`.
- Текст ссылки = `html.escape(work_item_id)`; `href` = `html.escape(url, quote=True)`.
- **Fail-safe:** если не хватает любого из (`web_base` валидный/не loopback, `workspace`,
`project_id`, `plane_issue_id`) → вернуть `html.escape(work_item_id)` (номер без ссылки).
- Логика построения URL уже существует в `_build_plane_issue_link` (ORCH-017) — допустимо
переиспользовать/обобщить её, разнеся «текст-ссылки = номер» и «текст-ссылки = `✅ Задача
в Plane`», чтобы не дублировать резолв проекта и loopback-guard.
### 3.2. Применение в карточке (требование 3)
- В `render_task_tracker` заголовок строится из `work_item_id`. Заменить
`html.escape(work_item_id)` в обоих вариантах заголовка (done / not-done) на
`plane_issue_link(work_item_id, plane_issue_id, repo=repo)` — номер становится
кликабельным.
- Для этого `render_task_tracker` должен дополнительно выбрать из БД `repo` и
`plane_issue_id` (расширить существующий `SELECT` по `tasks`). Схему НЕ менять — колонки
уже есть.
- `title` уже экранируется (`html.escape(title)`) — сохранить.
### 3.3. Применение во всех уведомлениях (требование 4)
Во всех точках `send_telegram`/`notify_*`, где в тексте есть `work_item_id`, заменить
«сырой» номер на `plane_issue_link(...)`. Перечень точек (из `src`):
- `src/notifications.py`: `notify_approve_requested`, `notify_error`
(и любые будущие notify_* с work_item_id);
- `src/stage_engine.py`: все `send_telegram(...)` с `work_item_id`
(≈ строки 613, 672, 719, 776, 820, 916, 971, 1057, 1134, 1192, 1228, 1257, 1355, 1367,
1425, 1447, 1601 — проверить каждую: применять ТОЛЬКО где упоминается номер задачи);
- `src/agents/launcher.py`: deploy-failed alert (≈685686), agent-failed alert (≈698699),
alert ≈821822;
- `src/merge_gate.py` (≈431432);
- `src/job_reaper.py` (≈395396);
- `src/security_gate.py` (≈673674);
- `src/reconciler.py` (≈449);
- `src/main.py` (≈4547).
`[ARCH]` Способ доступа к `plane_issue_id`/`project_id` в каждой точке (часто там уже есть
`work_item_id`, но не обязательно `plane_issue_id`): хелпер должен уметь резолвить
недостающее по `repo`/БД, оставаясь fail-safe. Допустимо добавить тонкую обёртку, которая по
`work_item_id`/`task_id` достаёт `repo`+`plane_issue_id` из БД и зовёт `plane_issue_link`
(аналогично существующему `_get_task_link_fields`). Везде, где данных нет — деградация на
просто номер, без падения.
### 3.4. HTML-экранирование
- `parse_mode=HTML` уже стоит в `send_telegram`/`edit_telegram`. Любой пользовательский
текст (title, описания, причины QG-fail, сообщения об ошибках), попадающий в сообщение с
ссылками, должен экранироваться `html.escape`, чтобы не сломать `<a>`-разметку.
---
## 4. Конфигурация
- `plane_web_url` (env `ORCH_PLANE_WEB_URL`) — уже существует (`src/config.py`), значение
прод — `plane.mva154.duckdns.org` (схему `https://` учесть при сборке URL).
Дополнительных полей конфигурации не требуется.
- `tracker_mode` — сменить дефолт на `bump` (раздел 1).
- Обновить `.env.example`, если в нём фигурируют `ORCH_TRACKER_MODE` / `ORCH_PLANE_WEB_URL`
(канон секретов/настроек — `.env.example`, не коммитить реальные секреты).
---
## 5. Артефакты pipeline, которые должны быть созданы/обновлены
- `docs/work-items/ORCH-067/06-adr/ADR-NNN-*.md` — архитектурное решение (минимум: источник
«истинного» Plane-статуса для веток при запрете изменения схемы БД; дефолт bump; единый
хелпер ссылки).
- `CLAUDE.md` — раздел про нотификации/tracker (дефолт bump; статус-строка карточки;
кликабельный номер в карточке и уведомлениях).
- `CHANGELOG.md` — запись ORCH-067.
- `docs/architecture/README.md` — при необходимости синхронизировать описание tracker'а.
---
## 6. Ограничения (что НЕ трогать)
- Транспорт `send_telegram`/`edit_telegram`/`delete_telegram`.
- Инвариант «одна карточка на задачу».
- Логику `disable_notification` (карточка тихая; пингуют только alert-хелперы).
- `STAGE_TRANSITIONS`, Quality Gates, схему БД.
- Поведение агентов/конвейера.
---
## 7. Замечания по самохостингу
Орк правит сам себя в проде (общий инстанс/БД с enduro-trails):
- НЕ перезапускать прод-контейнер `orchestrator` в рамках задачи.
- Обязательная страховка через `deploy-staging` (8501) до прод-деплоя.
- Смена дефолта `tracker_mode` затрагивает ВСЕ проекты — проверить отсутствие регресса для
enduro-trails (тесты + staging-наблюдение карточки).

View File

@@ -0,0 +1,129 @@
# Acceptance Criteria — ORCH-067
Work Item: **ORCH-067**
Каждый критерий формулирует чёткое условие PASS/FAIL. Привязка к тестам — в `04-test-plan.yaml`.
---
## Группа A — Bump по умолчанию (Требование 1)
### AC-1 — дефолт tracker_mode = bump
- **PASS:** `Settings().tracker_mode == "bump"` без выставленного env `ORCH_TRACKER_MODE`.
- **FAIL:** дефолт остался `"edit"` или иное.
### AC-2 — bump-поведение: одна карточка падает вниз
- **PASS:** при втором (и последующем) вызове `update_task_tracker` для задачи с уже
сохранённым `tracker_message_id` вызывается `delete_telegram(old_id)` (best-effort),
затем `send_telegram(...)` с `disable_notification=True`, затем `set_tracker_message_id`
на новый id. В чате остаётся ровно одна карточка на задачу.
- **FAIL:** карточка редактируется на месте при дефолте; либо появляются дубли; либо новая
карточка отправляется со звуком (`disable_notification` не True).
### AC-3 — bump fail-safe: транзиентный фейл send не обнуляет указатель
- **PASS:** если `send_telegram` вернул `None` (нет креды/транзиентный фейл),
`tracker_message_id` НЕ перезаписывается в `None` и дубликат в рамках вызова не создаётся.
- **FAIL:** указатель обнулён или создан второй card-месседж в одном вызове.
### AC-4 — режим edit остаётся доступен через env
- **PASS:** при `ORCH_TRACKER_MODE=edit` поведение прежнее (editMessageText, fallback на
новый месседж только при EDIT_GONE).
- **FAIL:** edit-режим сломан/недоступен.
---
## Группа B — Статус-строка карточки по модели ORCH-066 (Требование 2)
### AC-5 — статус-строка присутствует в карточке
- **PASS:** `render_task_tracker(task_id)` содержит явную строку текущего Plane-статуса
(напр. `📍 <status>`) в шапке/верхней части карточки.
- **FAIL:** статус-строки нет.
### AC-6 — корректный маппинг stage → Plane-статус
- **PASS:** для всех stage-выводимых состояний строка статуса соответствует таблице ТЗ §2.2:
`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`,
`development→Development`, `review→Code-Review`, `testing→Testing`,
`deploy→Awaiting Deploy`, `done→Done`.
- **FAIL:** хотя бы один stage маппится на неверное имя/внутренний ярлык.
### AC-7 — In Review (ожидание согласования BRD) как полноценный статус
- **PASS:** при `stage == "analysis"`, `brd_review_started_at` задан и
`brd_review_ended_at` пуст — статус-строка явно отражает `⏸️ In Review` с пометкой
«ожидание согласования BRD»; при этом существующая строка «Подтверждение BRD …» с ⏸️/⏳
сохранена. Работает без сетевых вызовов.
- **FAIL:** In Review теряется/не показан как статус, либо строка «Подтверждение BRD» исчезла.
### AC-8 — Awaiting Deploy и Needs Input отражены
- **PASS:** состояние ожидания Confirm Deploy показывается как
`⏸️ Awaiting Deploy — ожидание Confirm Deploy`; состояние вопросов аналитика — как
`❓ Needs Input — нужны уточнения`.
- **FAIL:** любое из этих состояний не отражено в статус-строке.
### AC-9 — рендер карточки никогда не падает
- **PASS:** при любой ошибке построения статуса (битые данные, недоступный источник)
`render_task_tracker` возвращает корректную карточку (деградация на stage-маппинг или
fallback-строку), исключение наружу не выходит.
- **FAIL:** `render_task_tracker` бросает исключение.
---
## Группа C — Кликабельный номер в карточке (Требование 3)
### AC-10 — номер задачи в карточке — гиперссылка
- **PASS:** при наличии `plane_web_url` (не loopback), `plane_workspace_slug`, `project_id`
(резолв по repo) и `plane_issue_id` карточка содержит
`<a href="https://<base>/<ws>/projects/<pid>/issues/<issue_id>/">ORCH-NNN</a>`.
- **FAIL:** номер выводится сырым текстом при наличии всех данных, либо URL собран неверно.
### AC-11 — fail-safe ссылки в карточке
- **PASS:** при отсутствии любого из (web_base/не-loopback, workspace, project_id,
plane_issue_id) карточка показывает номер БЕЗ ссылки (`html.escape(work_item_id)`) и не
падает.
- **FAIL:** падение, пустая ссылка `<a href="">`, либо битый `<a>` тег.
---
## Группа D — Кликабельный номер во всех уведомлениях (Требование 4)
### AC-12 — единый хелпер ссылки
- **PASS:** существует `plane_issue_link(...)`, возвращающий HTML-ссылку при достаточных
данных и `html.escape(work_item_id)` при недостаточных; никогда не бросает.
- **FAIL:** хелпера нет, либо он падает на неполных данных.
### AC-13 — хелпер применён во всех уведомлениях с work_item_id
- **PASS:** во всех точках `send_telegram`/`notify_*` из ТЗ §3.3, где упоминается
`work_item_id` (`notify_approve_requested`, `notify_error`, alert'ы stage_engine,
launcher, merge_gate, job_reaper, security_gate, reconciler, main), номер задачи
кликабелен (при наличии данных) и ведёт на ту же страницу Plane.
- **FAIL:** хотя бы одна такая точка выводит номер сырым текстом при наличии данных.
### AC-14 — HTML-экранирование пользовательского текста
- **PASS:** title/причины/сообщения с потенциальным HTML (`<`, `>`, `&`) экранируются
`html.escape`; разметка `<a>` остаётся валидной; сообщение проходит `parse_mode=HTML`.
- **FAIL:** неэкранированный текст ломает разметку (тест с title, содержащим `<b>`/`&`,
обнаруживает поломку).
---
## Группа E — Нерегресс и качество
### AC-15 — инварианты транспорта/нотификаций сохранены
- **PASS:** `send_telegram`/`edit_telegram`/`delete_telegram` не изменены по сигнатуре/
семантике; карточка тихая (`disable_notification=True`); инвариант «одна карточка на
задачу» соблюдён; `STAGE_TRANSITIONS`/QG/схема БД не тронуты.
- **FAIL:** изменён транспорт, карточка пингует, появились дубли, тронута схема БД/QG.
### AC-16 — нет регресса для enduro-trails
- **PASS:** существующие тесты нотификаций (`test_notify_approve_links.py`,
`test_notify_done_regression.py` и др.) проходят; поведение карточки для не-ORCH проектов
без новых Plane-статусов деградирует корректно (alias-fallback, без ссылки при нехватке
данных).
- **FAIL:** падение существующих тестов или сломанная карточка для enduro.
### AC-17 — весь набор тестов зелёный
- **PASS:** `pytest tests/ -q` зелёный.
- **FAIL:** любой упавший тест.
### AC-18 — документация обновлена в том же PR
- **PASS:** обновлены `CLAUDE.md` (раздел нотификаций/tracker), `CHANGELOG.md`,
создан ADR per-work-item.
- **FAIL:** функционал изменён, документация — нет (reviewer → REQUEST_CHANGES).

View File

@@ -0,0 +1,181 @@
work_item: ORCH-067
description: >
План тестов для ORCH-067 (Telegram tracker: bump по умолчанию, статус-строка
карточки по модели Plane ORCH-066, кликабельный номер задачи в карточке и во
всех уведомлениях орка). Сеть изолируется: send_telegram/edit_telegram/
delete_telegram подменяются рекордерами (как в tests/conftest.py и
tests/test_notify_approve_links.py); БД — временный SQLite, сидируемый фикстурой.
tests:
# --- Группа A: bump по умолчанию (AC-1..AC-4) ---
- id: TC-01
type: unit
description: "Дефолт Settings().tracker_mode == 'bump' без env ORCH_TRACKER_MODE"
module: tests/test_tracker_bump_default.py
asserts: "AC-1"
expected: PASS
- id: TC-02
type: unit
description: >
bump-поведение: при повторном update_task_tracker с сохранённым
tracker_message_id вызывается delete_telegram(old) -> send_telegram(...,
disable_notification=True) -> set_tracker_message_id(new). Одна карточка.
module: tests/test_tracker_bump_default.py
asserts: "AC-2"
expected: PASS
- id: TC-03
type: unit
description: >
bump fail-safe: send_telegram вернул None (нет креды/транзиент) ->
tracker_message_id не обнуляется, дубликат в вызове не создаётся.
module: tests/test_tracker_bump_default.py
asserts: "AC-3"
expected: PASS
- id: TC-04
type: unit
description: "ORCH_TRACKER_MODE=edit -> прежнее edit-поведение (editMessageText)"
module: tests/test_tracker_bump_default.py
asserts: "AC-4"
expected: PASS
# --- Группа B: статус-строка карточки (AC-5..AC-9) ---
- id: TC-05
type: unit
description: "render_task_tracker содержит явную строку текущего Plane-статуса"
module: tests/test_tracker_status_line.py
asserts: "AC-5"
expected: PASS
- id: TC-06
type: unit
description: >
Маппинг stage -> Plane-статус по таблице ТЗ §2.2: created->To Analyse,
analysis->Analysis, architecture->Architecture, development->Development,
review->Code-Review, testing->Testing, deploy->Awaiting Deploy, done->Done
(параметризованный тест по всем stage).
module: tests/test_tracker_status_line.py
asserts: "AC-6"
expected: PASS
- id: TC-07
type: unit
description: >
analysis + brd_review_started_at задан + brd_review_ended_at пуст ->
статус '⏸️ In Review' (ожидание согласования BRD); строка 'Подтверждение
BRD' с ⏸️/⏳ сохранена; без сетевых вызовов.
module: tests/test_tracker_status_line.py
asserts: "AC-7"
expected: PASS
- id: TC-08
type: unit
description: >
Awaiting Deploy ('ожидание Confirm Deploy') и Needs Input ('нужны
уточнения') корректно отражаются в статус-строке.
module: tests/test_tracker_status_line.py
asserts: "AC-8"
expected: PASS
- id: TC-09
type: unit
description: >
render_task_tracker не падает при битых/недоступных данных статуса
(деградация на stage-маппинг/fallback, исключение не наружу).
module: tests/test_tracker_status_line.py
asserts: "AC-9, AC-16"
expected: PASS
# --- Группа C: кликабельный номер в карточке (AC-10..AC-11) ---
- id: TC-10
type: unit
description: >
При полных данных (plane_web_url не loopback, workspace, project_id по repo,
plane_issue_id) карточка содержит <a href=".../issues/<id>/">ORCH-NNN</a>
с корректным URL.
module: tests/test_tracker_issue_link.py
asserts: "AC-10"
expected: PASS
- id: TC-11
type: unit
description: >
Fail-safe ссылки в карточке: при отсутствии любого из (web_base/не-loopback,
workspace, project_id, plane_issue_id) номер выводится html.escape без <a>,
рендер не падает. Параметризовать по каждому отсутствующему полю.
module: tests/test_tracker_issue_link.py
asserts: "AC-11"
expected: PASS
# --- Группа D: единый хелпер и уведомления (AC-12..AC-14) ---
- id: TC-12
type: unit
description: >
plane_issue_link(...) возвращает HTML-ссылку при достаточных данных и
html.escape(work_item_id) при недостаточных; никогда не бросает (в т.ч. на
None-аргументах и loopback-базе).
module: tests/test_plane_issue_link.py
asserts: "AC-12"
expected: PASS
- id: TC-13
type: unit
description: >
notify_approve_requested: номер задачи кликабелен (ведёт на страницу Plane),
сохранён call-to-action 'Approved', ровно одно notifying-сообщение.
module: tests/test_notify_issue_links.py
asserts: "AC-13"
expected: PASS
- id: TC-14
type: unit
description: >
notify_error: номер задачи кликабелен при наличии данных, деградирует на
сырой номер без падения при их отсутствии.
module: tests/test_notify_issue_links.py
asserts: "AC-13, AC-12"
expected: PASS
- id: TC-15
type: integration
description: >
Точки send_telegram в stage_engine/launcher/merge_gate/job_reaper/
security_gate/reconciler/main, где есть work_item_id, используют
plane_issue_link (или эквивалент) — номер кликабелен. Проверка рекордером
send_telegram на представительных alert-путях (deploy fail, agent fail,
QG fail, прод-деплой).
module: tests/test_notify_issue_links.py
asserts: "AC-13"
expected: PASS
- id: TC-16
type: unit
description: >
HTML-экранирование: title с '<b>'/'&'/'>' экранируется, <a>-разметка
остаётся валидной, сообщение не ломается под parse_mode=HTML (карточка и
уведомления).
module: tests/test_tracker_issue_link.py
asserts: "AC-14"
expected: PASS
# --- Группа E: нерегресс (AC-15..AC-18) ---
- id: TC-17
type: integration
description: >
Инварианты: карточка отправляется с disable_notification=True; одна карточка
на задачу; транспорт send/edit/delete не изменён по семантике.
module: tests/test_tracker_bump_default.py
asserts: "AC-15"
expected: PASS
- id: TC-18
type: integration
description: >
Нерегресс существующих тестов нотификаций (test_notify_approve_links.py,
test_notify_done_regression.py) и корректная деградация карточки для
enduro-trails без новых Plane-статусов.
module: tests/test_notify_done_regression.py
asserts: "AC-16, AC-17"
expected: PASS

View File

@@ -0,0 +1,224 @@
# ADR-001: Источник Plane-статуса для live-карточки и кликабельный номер задачи
- **Статус:** Proposed
- **Дата:** 2026-06-08
- **Задача:** ORCH-067
- **Слой:** B (индикация), НЕ слой A (машина стадий) — см. CLAUDE.md / ORCH-066
- **Связи:** ORCH-066 (статусная модель Plane, `_PLANE_NAME_TO_KEY` / `_STAGE_TO_STATE_KEY`),
ORCH-042 (live-tracker, режимы `edit`/`bump`), ORCH-017 (`_build_plane_issue_link`,
`plane_web_url`/`plane_workspace_slug`, loopback-guard), ORCH-059 (Confirm Deploy),
ORCH-060 (`fetch_issue_state`), ORCH-010 (`get_project_states` per-project + кэш),
adr-0001 (реестр проектов), adr-0010 (post-deploy monitor).
## Контекст
ТЗ ORCH-067 (`02-trz.md`) фиксирует объём изменений; данный ADR закрывает развилки,
явно отданные архитектору метками `[ARCH]`:
1. **Источник «истинного» Plane-статуса для веток, не выводимых из `tasks.stage`**
(Needs Input, Blocked, Rejected, Cancelled, Deploying, Monitoring after Deploy),
при **запрете менять схему БД** (нельзя добавить колонку-флаг). TZ §2.2 предлагает
два варианта: (а) best-effort чтение живого Plane-статуса с fail-safe;
(б) только stage-выводимые статусы.
2. **Способ доступа к `plane_issue_id`/`project_id`** в каждой точке `send_telegram`,
где есть только `work_item_id` (требование 4), оставаясь fail-safe.
3. Смена дефолта `tracker_mode` (`edit``bump`) для общего инстанса.
### Ключевая находка анализа (определяет развилку 1)
Когда аналитик задаёт вопросы, `stage_engine.start_pipeline` при наличии
`01-questions.md` вызывает `set_issue_needs_input(work_item_id)` (Plane → Needs Input),
но **DB-стадия остаётся `analysis`**, а BRD-часы (`brd_review_started_at`) **не
запускаются** (они стартуют позже, в `notify_approve_requested`, когда BRD готов).
Следовательно состояния **`Analysis` (аналитик работает)** и **`❓ Needs Input`
(аналитик ждёт ответа)** **неразличимы** по offline-данным БД (`stage` + brd-clock).
Единственный авторитетный источник этого различия — **живой Plane-статус**, который
оркестратор сам выставил через `set_issue_needs_input`.
То же касается `Deploying` / `Monitoring after Deploy`: на стадии `deploy`/`done`
конкретная фаза self-deploy видна только в Plane (ORCH-059/ORCH-066), не в `tasks.stage`.
Вывод: чисто-offline вариант (б) **не покрывает обязательный по DoD `❓ Needs Input`**
(AC-8). Нужен гибрид.
## Решение
### Р-1. Гибрид: offline-first ядро + best-effort live-overlay
Статус карточки строится в два слоя; **offline-ядро авторитетно и всегда работает без
сети**, live-overlay лишь дорисовывает ветки, неотличимые offline.
**Слой 1 — чистая offline-функция `plane_status_label(task_row) -> str`** в
`src/notifications.py`. Детерминированная, **никогда не бросает**, **никогда не ходит в
сеть**. Маппинг (имена статусов — финальные из ORCH-066 `_PLANE_NAME_TO_KEY`):
| Источник (DB) | Метка карточки |
|---|---|
| `stage == "created"` | `To Analyse` |
| `stage == "analysis"`, brd-clock не запущен | `Analysis` |
| `stage == "analysis"`, `brd_review_started_at` есть, `brd_review_ended_at` пуст | `⏸️ In Review — ожидание согласования BRD` |
| `stage == "architecture"` | `Architecture` |
| `stage == "development"` | `Development` |
| `stage == "review"` | `Code-Review` |
| `stage == "testing"` | `Testing` |
| `stage == "deploy"` | `⏸️ Awaiting Deploy — ожидание Confirm Deploy` |
| `stage == "done"` | `Done` |
| неизвестный/битый `stage` | дефолт: `html`-безопасная строка по `stage` (или `To Analyse`) |
Этого слоя достаточно для **`⏸️ In Review`** и **`⏸️ Awaiting Deploy`** — оба
обязательны по DoD и **работают без сети** (AC-7, AC-8). `In Review` выводится
исключительно из brd-clock.
**Слой 2 — best-effort live-overlay** `_live_plane_branch_override(repo, plane_issue_id,
base_label) -> str` для веток, неразличимых offline: **Needs Input, Blocked, Rejected,
Cancelled, Deploying, Monitoring after Deploy**. Алгоритм:
1. Резолв `project_id` по `repo` (`get_project_by_repo(repo).plane_project_id`).
2. `live_uuid = fetch_issue_state(plane_issue_id, project_id)` (ORCH-060) — **с коротким
таймаутом** (см. Р-4), не дефолтным 10s.
3. Сопоставление `live_uuid` с **конкретными** UUID веток из
`get_project_states(project_id)` (кэш ORCH-010): `needs_input`, `blocked`,
`cancelled`, `rejected`, `deploying`, `monitoring`.
4. Override применяется **только** если `live_uuid` совпал с одним из этих ключей.
Иначе возвращается `base_label` (offline-метка).
**Прецеденс (порядок приоритета):**
1. Если offline-ядро дало **`⏸️ In Review`** (brd-clock) — overlay **не вызывается**:
brd-clock авторитетнее возможно-устаревшего Plane-чтения для In Review.
2. Иначе `base_label` = offline-метка, затем применяется overlay (если включён и удался).
**Анти-false-positive на enduro (важно):** на enduro-trails ключи `deploying`/
`monitoring` алиасят UUID `in_progress`/`done` (`_STATE_ALIAS_FALLBACK`), поэтому прямое
сравнение UUID дало бы ложный `Deploying` для любой `in_progress`-задачи. Поэтому для
`deploying`/`monitoring` override применяется **только если** их UUID в
`get_project_states` **отличается** от UUID базового ключа (т.е. проект реально завёл
отдельный статус — это ORCH, не enduro). Ключи `needs_input/blocked/cancelled/rejected`
имеют отдельные UUID и на enduro, и на ORCH (`_DEFAULT_STATES`), поэтому различимы всегда.
### Р-2. Fail-safe и невлияние на конвейер (overlay)
- `_live_plane_branch_override` обёрнут в `try/except` и **никогда не бросает**; любая
ошибка/таймаут/нет сети/нет данных → возвращается `base_label`. Это удовлетворяет
«без сети не падать» и AC-9 (рендер карточки никогда не падает).
- Нет `plane_issue_id` / нет `project_id` / нет креды → overlay не вызывается, метка =
offline-ядро.
- **Kill-switch:** новый флаг конфигурации `tracker_live_status: bool = True`
(env `ORCH_TRACKER_LIVE_STATUS`). При `False` overlay полностью отключён (никаких
сетевых чтений в рендере) — карточка деградирует на offline-ядро. Это аварийный
тумблер и страховка от регресса для не-ORCH проектов. **Дефолт `True`**, иначе
обязательный по DoD `Needs Input` не отобразится из коробки.
### Р-3. Кэш live-статуса (защита hot-path)
`render_task_tracker` вызывается на КАЖДОМ обновлении трекера (старт/финиш агента,
переход стадии), а в режиме `bump`с delete+send каждый раз. Чтобы серия быстрых
перерисовок не била по Plane:
- Добавить **TTL-кэш per-issue** для `live_uuid` (ключ — `plane_issue_id`, TTL
`tracker_live_status_ttl_s: int = 60`). По образцу `_STATES_CACHE` в `plane_sync.py`.
- На промахе кэша — один `fetch_issue_state` с коротким таймаутом; результат кладётся в
кэш. На любой ошибке кэш не портится, возвращается offline-метка.
Это ограничивает сетевую нагрузку overlay ~одним GET в `TTL` на задачу.
### Р-4. Короткий таймаут live-чтения в рендере
`fetch_issue_state` (ORCH-060) хардкодит `timeout=10`. Для пути рендера это слишком
долго (рендер синхронный, в линии переходов общего конвейера). Решение: добавить в
`fetch_issue_state` **необязательный параметр `timeout`** (дефолт прежний `10`
обратная совместимость для reconciler), а overlay вызывает его с
`settings.tracker_live_status_timeout_s` (дефолт **3** с). Поведение/сигнатуры
существующих вызовов не меняются.
### Р-5. Единый хелпер кликабельного номера `plane_issue_link`
Добавить в `src/notifications.py`:
```python
def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str:
"""HTML с кликабельным номером (<a href=...>ORCH-NNN</a>) или html.escape(work_item_id).
Никогда не падает."""
```
- Переиспользовать логику и guard'ы `_build_plane_issue_link` (ORCH-017), **разнеся**
«текст ссылки = номер задачи» и «текст ссылки = `✅ Задача в Plane`», чтобы не
дублировать резолв проекта и loopback-guard. Рекомендуется выделить приватный
`_plane_issue_url(repo, plane_issue_id, project_id) -> str | None` (сборка URL +
loopback/workspace/project guard), который зовут оба: `plane_issue_link` (текст =
номер) и `_build_plane_issue_link` (текст = «✅ Задача в Plane»).
- База URL: `plane_web_url` → fallback `plane_api_url`; loopback → «нет web URL»
(`_is_loopback_base`).
- `project_id`: явный аргумент → иначе резолв по `repo`.
- URL: `{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`.
- Текст = `html.escape(work_item_id)`; `href` = `html.escape(url, quote=True)`.
- **Fail-safe:** не хватает любого из (web_base/не-loopback, workspace, project_id,
plane_issue_id) → вернуть `html.escape(work_item_id)` (номер без ссылки). Никогда не
бросает (AC-11, AC-12).
### Р-6. Доступ к `plane_issue_id`/`project_id` в точках уведомлений (требование 4)
В большинстве точек `send_telegram` доступен только `work_item_id`. Решение —
тонкая fail-safe обёртка по образцу `_get_task_link_fields`:
```python
def link_for(work_item_id, task_id=None) -> str:
"""По work_item_id (или task_id) достать repo+plane_issue_id из БД и вернуть
plane_issue_link(...). На любой нехватке данных -> html.escape(work_item_id)."""
```
- Если у точки есть `task_id` — читать `(repo, plane_issue_id)` напрямую из `tasks` по
`id`. Если только `work_item_id``SELECT repo, plane_issue_id FROM tasks WHERE
work_item_id=? ORDER BY id DESC LIMIT 1` (как в `_resolve_project_id`).
- Везде, где данных нет — деградация на `html.escape(work_item_id)`, без падения.
- Применить во всех точках из TZ §3.3 (`notify_approve_requested`, `notify_error`,
`stage_engine`, `launcher`, `merge_gate`, `job_reaper`, `security_gate`, `reconciler`,
`main`) — **только там, где упоминается номер задачи**.
### Р-7. `tracker_mode` дефолт → `bump`
`src/config.py`: `tracker_mode: str = "edit"``"bump"`. Инвариант «одна карточка на
задачу» сохранён в обоих режимах (код `update_task_tracker` не меняется по сути).
`edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`. Транзиентный фейл `send` не
обнуляет `tracker_message_id` (инвариант уже в коде — сохранить).
### Р-8. Чего НЕ делаем (границы)
- НЕ менять схему БД, `STAGE_TRANSITIONS`, Quality Gates, транспорт
`send_telegram`/`edit_telegram`/`delete_telegram`, `disable_notification`-семантику.
- НЕ менять поведение агентов/конвейера. Слой B (индикация) не управляет слоем A.
- НЕ добавлять блокирующих сетевых ожиданий в линию переходов сверх одного короткого
best-effort GET с кэшем (Р-3/Р-4).
- НЕ создавать глобальный (сквозной) ADR: изменение локально для `notifications.py` +
один config-дефолт, не вводит новую стадию/QG/компонент. Достаточно per-work-item ADR.
## Последствия
**Плюсы**
- Обязательные по DoD `⏸️ In Review`, `⏸️ Awaiting Deploy` работают **без сети**
(детерминированно, тестируемо offline — AC-6/AC-7).
- `❓ Needs Input` (и Blocked/Rejected/Cancelled/Deploying/Monitoring) отражаются через
авторитетный источник — живой Plane-статус, который иначе невосстановим из БД.
- Единый хелпер ссылки убирает дублирование резолва проекта/loopback-guard (ORCH-017).
- Kill-switch + кэш + короткий таймаут ограничивают риск для общего инстанса.
**Минусы / ограничения**
- Overlay добавляет ≤1 короткий GET (3 с таймаут) на задачу в `TTL=60s` в путь рендера.
Митигировано кэшем, таймаутом и kill-switch.
- При недоступном Plane ветки `Needs Input`/`Blocked`/… деградируют на offline-метку
(`Analysis`/stage). Это осознанный, безопасный компромисс (рендер важнее точности
ветки; конвейер не блокируется).
- На частично сконфигурированном проекте без отдельных статусов `Deploying`/`Monitoring`
эти ветки не показываются (alias-guard) — корректная деградация, не баг.
**Риски** — см. `10-tech-risks.md`.
## Альтернативы (отклонены)
- **Только offline (вариант б TZ).** Отклонён: не отличает `Needs Input` от `Analysis`
→ не покрывает обязательный AC-8.
- **Чтение `01-questions.md` из worktree как offline-сигнал Needs Input.** Отклонён:
хрупко (резолв пути worktree из `notifications.py`, файл может пережить ответ,
гонки) — менее надёжно, чем авторитетный Plane-статус.
- **Добавить DB-колонку-флаг для ветки.** Запрещено TZ (без изменения схемы).
- **Асинхронный фон/демон для подтяжки статуса.** Избыточно для слоя индикации; кэш +
короткий таймаут дешевле и проще, без нового компонента.

View File

@@ -0,0 +1,46 @@
# Инфраструктурные требования — ORCH-067
Топология не меняется (никаких новых контейнеров/портов/сервисов). Изменения —
**только конфигурация/env** и обязательный staging-гейт (self-hosting).
## 1. Изменения конфигурации (`src/config.py`)
| Поле | env | Старое | Новое | Назначение |
|---|---|---|---|---|
| `tracker_mode` | `ORCH_TRACKER_MODE` | `"edit"` | `"bump"` (дефолт) | Карточка падает вниз ленты при обновлении (ADR-001 Р-7). `edit` доступен через env. |
| `tracker_live_status` | `ORCH_TRACKER_LIVE_STATUS` | — (нет) | `True` (дефолт) | Kill-switch live-overlay Plane-статуса (ADR-001 Р-2). `0/false` → только offline-метки, без сетевых чтений в рендере. |
| `tracker_live_status_ttl_s` | `ORCH_TRACKER_LIVE_STATUS_TTL_S` | — | `60` | TTL per-issue кэша live-статуса (ADR-001 Р-3). |
| `tracker_live_status_timeout_s` | `ORCH_TRACKER_LIVE_STATUS_TIMEOUT_S` | — | `3` | Короткий таймаут live-чтения в рендере (ADR-001 Р-4). |
Уже существующие (не менять, использовать): `plane_web_url`
(`ORCH_PLANE_WEB_URL`, прод — `https://plane.mva154.duckdns.org`),
`plane_workspace_slug` (прод — `ag_proj`), `plane_api_url`.
## 2. `.env` / `.env.example`
- Обновить `.env.example`: добавить `ORCH_TRACKER_MODE`, `ORCH_PLANE_WEB_URL`,
`ORCH_TRACKER_LIVE_STATUS*` с дефолтами и комментариями (канон настроек —
`.env.example`, реальные секреты не коммитить).
- На прод-хосте допустимо явно выставить `ORCH_TRACKER_MODE=bump` как страховку, но код
обязан работать «из коробки» и без env.
- `ORCH_PLANE_WEB_URL` должен быть задан на проде (иначе номер задачи деградирует на
текст без ссылки — fail-safe, не падение).
## 3. Self-hosting (обязательно)
- **НЕ перезапускать / не ронять** прод-контейнер `orchestrator` (8500) в рамках задачи —
общий инстанс/БД с enduro-trails.
- Обязательная страховка через `deploy-staging` (8501, изолированная БД) **до** прод-деплоя.
На staging проверить:
- режим `bump`: одна карточка на задачу, падает вниз, тихо (без звука), без дублей;
- статус-строка: `⏸️ In Review`, `⏸️ Awaiting Deploy`, `❓ Needs Input` отображаются;
- кликабельный номер ведёт на страницу Plane;
- **нет регресса для enduro-trails** (карточка без новых статусов деградирует корректно).
- Прод-деплой орка — только переводом задачи на стадии `deploy` в статус
**«Confirm Deploy»** (ORCH-059), не `Approved`.
## 4. Сетевые требования
- Live-overlay требует доступности Plane API (`plane_api_url`) из контейнера — он уже
есть (используется plane_sync). Недоступность Plane → graceful degrade на offline-метку,
конвейер не блокируется (короткий таймаут + kill-switch).

View File

@@ -0,0 +1,35 @@
# Требования к данным — ORCH-067
## Изменения схемы БД: НЕТ
`STAGE_TRANSITIONS`, таблицы и колонки `tasks`/`agent_runs` **не меняются**. Это жёсткое
ограничение TZ §6 и предпосылка ADR-001 (запрет колонки-флага для веток статуса).
## Читаемые колонки `tasks` (существующие)
| Колонка | Использование в ORCH-067 |
|---|---|
| `id` | Ключ задачи. |
| `work_item_id` | Текст номера (`ORCH-NNN`) + ключ резолва в `link_for`. |
| `title` | Заголовок карточки (`html.escape`). |
| `stage` | Offline-маппинг Plane-статуса (ADR-001 Р-1, слой 1). |
| `brd_review_started_at`, `brd_review_ended_at` | Различение `Analysis``⏸️ In Review` (offline, без сети). |
| `repo` | Резолв `project_id` (`get_project_by_repo`) для ссылки и live-overlay. |
| `plane_issue_id` (UUID) | `issue_id` в URL Plane + аргумент `fetch_issue_state` (live-overlay). |
| `created_at`, `updated_at` | Тоталы времени в done-строке (без изменений). |
`render_task_tracker` **расширяет существующий `SELECT`** по `tasks`, добавляя `repo` и
`plane_issue_id` к уже выбираемым полям. Схему это не трогает — колонки уже есть.
## Кэш в памяти (не БД)
Per-issue TTL-кэш live-статуса (ключ `plane_issue_id`, TTL
`tracker_live_status_ttl_s=60`, ADR-001 Р-3) — **in-memory**, по образцу `_STATES_CACHE`
в `plane_sync.py`. Не персистится, переживание рестарта не требуется (best-effort
индикация). Очистка при рестарте — допустима.
## Источник имён статусов
Имена и логические ключи статусов берутся из существующих структур `src/plane_sync.py`
(`_PLANE_NAME_TO_KEY`, `get_project_states`, `_DEFAULT_STATES`), вводимых ORCH-066.
Новых статусов/ключей ORCH-067 **не добавляет**.

View File

@@ -0,0 +1,21 @@
# Технические риски — ORCH-067
| # | Риск | Вероятность / Влияние | Митигация (ADR-001) | Остаточный риск |
|---|---|---|---|---|
| R-1 | **Регресс enduro-trails** при смене дефолта `tracker_mode``bump` (другое поведение карточки для всех проектов). | Сред / Сред | Инвариант «одна карточка на задачу» сохранён; `edit` доступен через env; проверка на staging + тесты нерегресса (AC-16). | Низкий |
| R-2 | **Поломка HTML-разметки** неэкранированным `title`/причиной → сообщение с `parse_mode=HTML` не доставится. | Сред / Сред | Обязательный `html.escape` для всего пользовательского текста; `href` через `html.escape(url, quote=True)`; тест с `<b>`/`&` (AC-14). | Низкий |
| R-3 | **Latency в hot-path конвейера**: live-overlay добавляет сетевой GET в синхронный рендер, вызываемый на каждом переходе/в bump. | Сред / Сред | Короткий таймаут 3 с (Р-4) + per-issue TTL-кэш 60 с (Р-3) + kill-switch `ORCH_TRACKER_LIVE_STATUS=0` (Р-2). ≤1 GET на задачу за TTL. | Низкий |
| R-4 | **Рендер карточки падает** на битых данных/недоступном Plane. | Низк / Выс | `plane_status_label` чистая и never-raise; overlay в `try/except` → degrade на offline-метку; `render_task_tracker` уже never-raise (AC-9). | Очень низкий |
| R-5 | **Ложный `Deploying`/`Monitoring` на enduro** (их UUID алиасит `in_progress`/`done`). | Сред / Низк | Override этих веток только если UUID статуса ≠ UUID базового ключа в `get_project_states` (Р-1, anti-false-positive). | Очень низкий |
| R-6 | **Устаревший Plane-статус из кэша** показывает неактуальную ветку (например, `Needs Input` после ответа). | Сред / Низк | TTL 60 с самозаживает; offline-ядро авторитетно для In Review (brd-clock не оверрайдится). Индикация, не управление — расхождение косметическое. | Низкий |
| R-7 | **Транзиентный фейл `send` плодит дубли / обнуляет указатель** в bump. | Низк / Сред | Инвариант уже в коде (`set_tracker_message_id` только при `new_mid is not None`); не менять; тест AC-3. | Низкий |
| R-8 | **Self-hosting**: деплой орка ломает общий инстанс (enduro + ORCH, общая БД/очередь). | Низк / Выс | Обязательный staging-гейт (8501) до прода; прод-контейнер не ронять в задаче; прод-деплой только через «Confirm Deploy». | Низкий |
| R-9 | **Пропущенная точка** уведомления с сырым номером (требование 4 — много call-sites). | Сред / Низк | Единый `link_for`/`plane_issue_link`; чек-лист точек из TZ §3.3; reviewer проверяет покрытие (AC-13). | Низкий |
| R-10 | **Рассинхрон имён статусов** с ORCH-066, если та не в проде на момент разработки. | Низк / Низк | Имена берутся из `_PLANE_NAME_TO_KEY` (golden source); делать после прода ORCH-066 (BRD §6). | Низкий |
## Сводно
Все остаточные риски — низкие/очень низкие после митигаций. Главные защитные контуры:
(1) offline-ядро статуса не требует сети и детерминировано; (2) live-overlay полностью
best-effort с таймаутом+кэшем+kill-switch; (3) обязательный staging-гейт перед прод-деплоем
общего инстанса (self-hosting).

View File

@@ -0,0 +1,78 @@
---
type: review
work_item_id: ORCH-067
verdict: APPROVED
version: 2
---
# Review ORCH-067
## Summary
Повторное ревью после фикса документации (коммит `7a88f39`). Реализация полностью
соответствует ТЗ (`02-trz.md`), ADR-001 и всем acceptance criteria (`03-acceptance-criteria.md`).
**Код** (`src/notifications.py` — ядро):
- **Req 1 (bump):** дефолт `tracker_mode` сменён `edit → bump` (`src/config.py`); логика
`update_task_tracker`, транспорт `send/edit/delete_telegram`, `disable_notification` и
инвариант «одна карточка на задачу» не тронуты (AC-1..AC-4, AC-15 ✓).
- **Req 2 (статус-строка):** чистый never-raise `plane_status_label(task_row)` (offline-ядро:
stage→статус + `⏸️ In Review` из brd-clock + `⏸️ Awaiting Deploy`, всё без сети) +
best-effort `_live_plane_branch_override` для ветвей, неотличимых offline (Needs Input /
Blocked / Rejected / Cancelled / Deploying / Monitoring). Kill-switch
(`tracker_live_status`), per-issue TTL-кэш (`_LIVE_STATE_CACHE`), короткий таймаут
(`fetch_issue_state(..., timeout=)`, дефолт 10 сохранён → нет регресса reconciler).
Anti-false-positive guard для enduro (`_LIVE_BRANCH_BASE`: deploying/monitoring override
только при отдельном UUID). Прецеденс In Review > overlay соблюдён. `_card_status_label`
обёрнут в try/except → рендер никогда не падает (AC-5..AC-9 ✓).
- **Req 3+4 (кликабельный номер):** единый `_plane_issue_url` устраняет дублирование
резолва проекта/loopback-guard (ORCH-017); `plane_issue_link` (текст=номер) и
`_build_plane_issue_link` (текст=«✅ Задача в Plane») оба зовут его. `link_for` fail-safe
достаёт `repo`/`plane_issue_id` из БД. Применено в заголовке карточки и во ВСЕХ точках
§3.3 с номером задачи (AC-10..AC-14 ✓).
**Точки §3.3 проверены пофайлово:** `notify_approve_requested`, `notify_error`,
`stage_engine.py` (все alert'ы с номером), `agents/launcher.py`, `security_gate.py`,
`reconciler.py` — номер кликабелен. `merge_gate.py`/`job_reaper.py`/`main.py` оставлены без
ссылки **осознанно и корректно**: их тексты ссылаются на repo/job/run_id, а НЕ на
`work_item_id` (проверено: merge_gate:432 — lease/repo, job_reaper:396 — job/agent/repo,
main:47 — orphaned run_ids).
**Инварианты/нерегресс:** схема БД, `STAGE_TRANSITIONS`, QG, транспорт — не тронуты
(AC-15 ✓). `get_db()` возвращает новое соединение на вызов, поэтому `conn.close()` в
`link_for` корректен. `pytest tests/ -q`**907 passed** (AC-16, AC-17 ✓).
**Документация (блокеры v1 закрыты):** `CHANGELOG.md`, `CLAUDE.md`, `.env.example`
обновлены в коммите `7a88f39`; ADR-001 присутствует и полон; `README.md`/`internals.md`
синхронизированы (AC-18 ✓).
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- (нет)
### P3 — Nice to have (не блокирует)
- [ ] Часть alert-сообщений в `stage_engine.py` (`_handle_self_deploy_phase_b`,
`_handle_merge_verify`) встраивает «сырой» `{msg}`/`{e}`/`{reason}` рядом с новой
`<a>`-ссылкой; под `parse_mode=HTML` редкий `<` в этих подстановках теоретически мог
бы помешать рендеру. Это **пре-существующее поведение** (parse_mode=HTML стоял и
раньше), не регресс данной задачи; `notify_error` свой `error` экранирует. Можно при
случае обернуть прочие подстановки в `html.escape`.
## Документация
- `docs/architecture/README.md` — обновлён (компонент Notifications / live-tracker). ✓
- `docs/architecture/internals.md` — обновлён (§7: bump/edit, Plane-статус, кликабельный номер). ✓
- `06-adr/ADR-001-tracker-plane-status-and-link.md` — присутствует, полный, закрывает все `[ARCH]`. ✓
- `CHANGELOG.md` — обновлён (запись ORCH-067). ✓
- `CLAUDE.md` — обновлён (раздел «Нотификации / Telegram live-tracker»). ✓
- `.env.example` — синхронизирован (`ORCH_TRACKER_MODE=bump` + новые флаги live-overlay). ✓
Документация = golden source: код и доку обновлены в одном PR. Блокеры предыдущего ревью
(v1) закрыты. Замечаний уровня P0/P1/P2 нет → **APPROVED**.

View File

@@ -0,0 +1,78 @@
---
type: test-report
work_item_id: ORCH-067
result: PASS
---
# Test Report — ORCH-067
Telegram tracker: bump по умолчанию, статус-строка карточки по модели Plane (ORCH-066),
кликабельный номер задачи в карточке и во всех уведомлениях орка.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: `feature/ORCH-067-telegram-tracker-bump-plane` (worktree)
- Дата: 2026-06-08
- Review-вердикт: APPROVED (`12-review.md`, version 2)
## Smoke test API (prod, :8500)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | PASS — отдаёт active_tasks (ORCH-067 на stage=testing) |
| `GET /queue` | PASS — breaker closed, preflight_ok, counts корректны |
Прод-контейнер не перезапускался (self-hosting инвариант соблюдён).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Модуль | AC | Результат |
|-------|----------|--------|----|-----------|
| TC-01 | Дефолт `tracker_mode == "bump"` без env | test_tracker_bump_default.py | AC-1 | PASS |
| TC-02 | bump: delete(old)→send(silent)→repoint, одна карточка | test_tracker_bump_default.py | AC-2 | PASS |
| TC-03 | bump fail-safe: send=None не обнуляет указатель | test_tracker_bump_default.py | AC-3 | PASS |
| TC-04 | `ORCH_TRACKER_MODE=edit` — прежнее поведение | test_tracker_bump_default.py | AC-4 | PASS |
| TC-05 | Карточка содержит строку Plane-статуса | test_tracker_status_line.py | AC-5 | PASS |
| TC-06 | Маппинг stage → Plane-статус (§2.2, параметризованный) | test_tracker_status_line.py | AC-6 | PASS |
| TC-07 | In Review из brd-clock, без сети; строка «Подтверждение BRD» сохранена | test_tracker_status_line.py | AC-7 | PASS |
| TC-08 | Awaiting Deploy + Needs Input отражены | test_tracker_status_line.py | AC-8 | PASS |
| TC-09 | render_task_tracker не падает на битых данных | test_tracker_status_line.py | AC-9, AC-16 | PASS |
| TC-10 | Кликабельный номер в карточке при полных данных | test_tracker_issue_link.py | AC-10 | PASS |
| TC-11 | Fail-safe ссылки в карточке (параметризованный) | test_tracker_issue_link.py | AC-11 | PASS |
| TC-12 | `plane_issue_link(...)` — ссылка/escape, никогда не бросает | test_plane_issue_link.py | AC-12 | PASS |
| TC-13 | notify_approve_requested: номер кликабелен, одна нотификация | test_notify_issue_links.py | AC-13 | PASS |
| TC-14 | notify_error: кликабелен/деградирует без падения | test_notify_issue_links.py | AC-13, AC-12 | PASS |
| TC-15 | Точки send_telegram (stage_engine/launcher/merge_gate/job_reaper/security_gate/reconciler/main) используют хелпер | test_notify_issue_links.py | AC-13 | PASS |
| TC-16 | HTML-экранирование title/`&`, валидность `<a>` | test_tracker_issue_link.py | AC-14 | PASS |
| TC-17 | Инварианты транспорта: disable_notification, одна карточка | test_tracker_bump_default.py | AC-15 | PASS |
| TC-18 | Нерегресс нотификаций + деградация для enduro-trails | test_notify_done_regression.py | AC-16, AC-17 | PASS |
Все 18 TC из тест-плана — PASS. Целевые модули: **57 passed**.
## Покрытие acceptance criteria
AC-1..AC-18 — все покрыты соответствующими TC и зелёные. AC-17 (полный набор) подтверждён
прогоном всего пакета.
## Вывод pytest (полный регресс)
```
$ python -m pytest tests/ -v --tb=short
...
======================= 907 passed, 1 warning in 22.36s ========================
```
Единственный warning — пре-существующий `PydanticDeprecatedSince20` в `src/config.py:4`
(не относится к ORCH-067, не регресс).
Целевые модули задачи:
```
$ python -m pytest tests/test_tracker_bump_default.py tests/test_tracker_status_line.py \
tests/test_tracker_issue_link.py tests/test_plane_issue_link.py \
tests/test_notify_issue_links.py tests/test_notify_done_regression.py -q
57 passed, 1 warning in 1.39s
```
## Итог
**PASS** — 907/907 тестов зелёные, все 18 TC и AC-1..AC-18 выполнены, smoke API OK,
нерегресс для enduro-trails подтверждён. Задача готова к переходу на `deploy-staging`.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: reconciler livelock — спам unblock done-задачи (ET-002)
Work Item ID: ORCH-068
## Description
TBD

View File

@@ -0,0 +1,52 @@
# BRD — ORCH-068: BUG reconciler livelock (спам unblock done-задачи)
## 1. Контекст и предыстория
Reconciler (`src/reconciler.py`, ORCH-053) — фоновый поток, который доигрывает пропущенные webhook-переходы. Ветвь **F-2 (plane-side)** опрашивает Plane per-project и реплеит In Progress / Approved / Rejected через штатные `handle_status_start` / `handle_verdict`. При фактической разблокировке вызывается `_note_unblock` → лог + Telegram.
ORCH-066 (мердж `feature/ORCH-066-plane`, прод 2026-06-07 ~22:16 UTC) ввела новую статусную модель Plane (маппинг стадия↔статус, новые имена `Done`, `Monitoring after Deploy` и т.п.). Это спровоцировало регрессию в F-2.
## 2. Проблема (бизнес-симптом)
С 22:17 UTC (рестарт прод-контейнера после деплоя ORCH-066) reconciler каждые ~120с шлёт в Telegram:
```
reconciler: ET-002 done разблокирована (потерян webhook)
```
для задачи **ET-002** (enduro-trails), которая в Done с 2026-05-21. На момент анализа — 191+ сообщений подряд, поток не прекращается (ночной спам, найден Славой 2026-06-08 01:22 UTC).
**Ключевой факт:** ET-002 полностью синхронизирована — БД `stage=done`, Plane `state=Done`. Reconciler обязан молчать (инвариант «silence when in sync», AC-9/AC-10 из ORCH-053), но шлёт уведомления вхолостую.
## 3. Диагностика (проведена, root cause найден)
1. **Деньги/токены НЕ тратятся:** `jobs` / `agent_runs` за 4ч пусты; `advance_stage` для done = no-op; `handle_verdict` для done-задачи ничего не меняет. Это «дешёвый», но шумный и подрывающий доверие баг (livelock + ложный alert-fatigue).
2. **Механизм:**
- `_reconcile_plane_project` (`src/reconciler.py` ~241) тянет `list_issues_by_state(pid, [in_progress, approved, rejected])`.
- На enduro-trails статусы «схлопнуты»: после ORCH-066 терминальный `Done` алиасится под UUID `approved` (см. ниже п.4) → ET-002 (Plane=Done) **попадает** в actionable-выборку.
- В `_reconcile_plane_issue` (~295) срабатывает ветка `new_state == approved and task is not None``handle_verdict(approved)` (no-op, задача уже done) **+ безусловный `_note_unblock`**.
- `_note_unblock` (~317) вызывается **сразу после `_dispatch`, не проверяя фактическое изменение состояния** — хотя его docstring обещает «fires only on an actual state change, never per idle tick». Инвариант нарушен.
3. **Два независимых дефекта складываются:**
- **D1 (выборка):** терминальные статусы (`Done`/`Cancelled`) не исключены из actionable-набора F-2; на «схлопывающих» проектах Done не отличается от approved по голому UUID.
- **D2 (нотификация):** `_note_unblock` срабатывает безусловно после no-op dispatch, а не только при подтверждённом state change.
4. **Почему `get_project_states` схлопывает:** функция строит маппинг по *именам* статусов из Plane API, затем недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После ORCH-066 набор статусов enduro изменился — голый UUID перестал однозначно различать `Done` (completed-группа) и `approved` (review). Группа состояния (`state.group`) при этом различает их корректно, но в коде не используется.
## 4. Связанный баг (BUG КЭША СТАТУСОВ, найден 2026-06-07 при деплое ORCH-066)
`_STATES_CACHE` (`src/plane_sync.py` ~134) кэширует статусы Plane на **весь lifetime процесса**. После создания нового Plane-статуса (напр. `Confirm Deploy`) боевой процесс держит устаревший набор → webhook на новый статус даёт «no pipeline action» (Phase B не триггерится). Лечилось только рестартом орка. Примитив сброса уже есть — `reload_project_states()` — но он нигде не вызывается автоматически.
Оба бага — следствие хрупкости статусной модели после ORCH-066. **Решение:** вести их в одном work item (см. scope ниже), окончательное разделение — на усмотрение архитектора.
## 5. Цели (Goals)
- G1. Reconciler НЕ шлёт «разблокирована» для синхронизированной done/cancelled задачи (восстановить инвариант silence-when-in-sync).
- G2. `_note_unblock` срабатывает **только** при реальном state change (соблюдён AC-9/AC-10).
- G3. Дедуп: нет повторного спама по той же задаче без изменения её состояния.
- G4. Корректное различение терминальных (`Done`/`Cancelled`) и review-статусов (`approved`/`rejected`) даже на проектах, «схлопывающих» их по UUID — на всех проектах (enduro И orchestrator).
- G5 (secondary). Устаревший `_STATES_CACHE` обновляется без рестарта процесса (TTL / flush-on-unknown / endpoint).
## 6. Не-цели (Out of scope)
- N1. Менять source-of-truth: ориентир F-2 на Plane **остаётся** корректным по дизайну (таблица `tasks` без status-колонки; статусы двигает человек в Plane). Идею F-2 НЕ переписываем — баг в маппинге/нотификации, не в концепции.
- N2. Менять реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схему БД, контракты гейтов.
- N3. Менять поведение F-1 (gate-side) и F-3.
- N4. Полный авто-approve деплоя (ORCH-54).
## 7. Затронутые стороны
- **Все проекты на одном инстансе** (enduro-trails + orchestrator, общая БД/очередь) — баг проявился на ET-002, но фикс выборки терминалов обязан быть проектно-независимым.
- **Self-hosting:** правка идёт в работающий прод-инструмент → обязательна страховка staging (8501), запрет на рестарт прод-контейнера в рамках задачи.
## 8. Критерий успеха (бизнес)
Тик reconciler для синхронизированной done/cancelled задачи = **0 уведомлений, 0 jobs, 0 токенов**. Telegram-спам прекращён. Легитимная разблокировка (реально потерянный approved/in_progress webhook) по-прежнему работает (нет регресса F-2).

View File

@@ -0,0 +1,68 @@
# ТЗ — ORCH-068: устранить livelock reconciler F-2 + спам unblock done-задачи
> Документ описывает ТРЕБОВАНИЯ к изменению поведения (что и где), а не выбор реализации. Конкретный механизм (state.group vs явный allowlist терминалов; TTL vs flush-on-unknown) — решение архитектора в ADR.
## 1. Затронутые модули `src/`
| Модуль | Роль в баге | Требуемое изменение |
|--------|-------------|---------------------|
| `src/reconciler.py` | F-2 `_reconcile_plane_project` / `_reconcile_plane_issue` / `_note_unblock` | Исключить терминальные статусы из actionable-выборки; `_note_unblock` только при подтверждённом state change; дедуп. |
| `src/plane_sync.py` | `get_project_states`, `list_issues_by_state`, `_STATES_CACHE` | Дать способ различать терминальные/review статусы (группа состояния); устранить вечно-устаревший кэш (TTL/flush). |
| `src/config.py` | флаги | (если нужны) новые kill-switch/настройки TTL — с дефолтами, не меняющими прод-инварианты. |
| `src/main.py` (`/queue`) | наблюдаемость | (опц.) отразить дедуп/skip-терминалов в снимке `reconcile`. |
**НЕ трогать:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), схему БД, контракты `handle_status_start` / `handle_verdict`, F-1 (`reconcile_gate_once`), F-3.
## 2. Требования к F-2 (src/reconciler.py)
### TR-1. Исключить терминальные статусы из actionable-выборки
`_reconcile_plane_project` НЕ должен подавать задачи в терминальном Plane-статусе (`Done` и прочие completed-группы, `Cancelled`) ни в `list_issues_by_state`, ни в последующее сравнение веток.
- Требование проектно-независимое: работает на enduro И orchestrator, независимо от того, «схлопывает» ли проект статусы по UUID.
- Различение `Done`/`Cancelled` (completed) от `approved`/`rejected` (review) НЕ должно опираться только на голый UUID, если проект их алиасит. Допустимый ориентир — группа состояния Plane (`state.group`: `completed`/`started`/`unstarted`/`backlog`/`cancelled`) либо явный набор логических ключей терминалов. Выбор — за архитектором.
### TR-2. `_note_unblock` — только при реальном state change
`_note_unblock` (лог + Telegram + инкремент `unblocked_total`) ВЫЗЫВАЕТСЯ ТОЛЬКО когда диспетчеризованный обработчик фактически изменил состояние задачи (advance / replayed transition, реально сдвинувший стадию). No-op dispatch (задача уже в целевом состоянии) → нотификация НЕ отправляется.
- Сейчас `_dispatch` (`asyncio.run(coro_fn(...))`) отбрасывает результат, а `_note_unblock` зовётся безусловно. Требуется механизм подтверждения изменения (напр. сравнение стадии задачи до/после dispatch через `get_task_by_plane_id`, либо проброс сигнала из обработчика). Конкретику выбирает архитектор; контракт обработчиков `handle_*` менять НЕ обязательно (предпочтительно сравнение состояния до/после на стороне reconciler).
- Восстановить соответствие docstring `_note_unblock`: «Fires only on an actual state change … never per idle tick».
### TR-3. Дедуп / идемпотентность нотификаций
Reconciler НЕ должен слать повторное уведомление о той же задаче, если её состояние не менялось с прошлого тика. TR-1+TR-2 закрывают основной кейс (done более не входит в выборку и не нотифицируется); TR-3 — дополнительная страховка (best-effort), чтобы любой будущий no-op путь не дал повторного спама.
## 3. Требования к статус-кэшу (src/plane_sync.py) — secondary
### TR-4. Устаревший `_STATES_CACHE` обновляется без рестарта
После появления нового Plane-статуса процесс не должен бесконечно держать устаревший набор. Допустимые подходы (выбор архитектора, можно комбинировать):
- TTL на запись кэша (напр. `ORCH_PLANE_STATES_TTL_S`, дефолт разумный, 0/неуст. = прежнее поведение для совместимости);
- flush-on-unknown: при детекте неизвестного статуса в вебхуке/реконсилере — вызвать существующий `reload_project_states(pid)` и перезапросить;
- админ-эндпоинт/сигнал для ручного flush без рестарта.
`reload_project_states()` уже существует — переиспользовать как примитив сброса, новую логику сброса не дублировать.
## 4. Изменения API
- Новых обязательных endpoint'ов нет.
- Опционально (TR-4, на усмотрение архитектора): admin-эндпоинт сброса кэша статусов (напр. `POST /admin/plane-states/reload`) — если выбран этот вариант flush. Должен быть защищён/идемпотентен; документировать в README таблице API.
- Снимок `GET /queue` (блок `reconcile`) — без ломающих изменений; допустимо добавить поля наблюдаемости (skip-терминалов / dedup-счётчик).
## 5. Изменения схемы БД
**Нет.** Дедуп TR-3 реализуется in-memory (best-effort, как существующие счётчики `unblocked_total`/`last_unblocked`, AC-11 ORCH-053 допускает их сброс при рестарте) либо через сравнение живого состояния Plane/БД. Миграции не требуются.
## 6. Требования к новым QG checks
Нет. Реестр `QG_CHECKS` не меняется.
## 7. Инварианты (обязаны сохраниться)
- INV-1. Source of truth F-2 — Plane (НЕ меняем).
- INV-2. never-raise на единицу работы (per-issue / per-project / per-tick) сохранён.
- INV-3. Kill-switch `ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2) — работают.
- INV-4. F-1 / F-3 поведение не изменено.
- INV-5. 0 jobs / 0 токенов для синхронизированных задач (как сейчас) сохранено.
- INV-6. Легитимная разблокировка реально-потерянного approved/in_progress webhook продолжает работать (нет регресса F-2).
- INV-7. Self-hosting: тик reconciler НИКОГДА не рестартит/не роняет прод-контейнер.
## 8. Артефакты pipeline, которые надо обновить в ТОМ ЖЕ PR
- `CLAUDE.md` — если меняется наблюдаемое поведение reconciler/кэша (раздел про reconciler/правила).
- `docs/architecture/README.md` — секция «Reconciler … (ORCH-053)»: уточнить исключение терминалов + дедуп; при TR-4 — секция «Plane Sync».
- `docs/architecture/adr/adr-0007-reconciler.md` (или новый per-WI ADR `docs/work-items/ORCH-068/06-adr/ADR-001-…`) — зафиксировать решение по терминалам/группе состояния и по кэшу.
- `CHANGELOG.md` — запись о фиксе (`fix:`).
- `.env.example` / `.env.staging` — если введён новый флаг (TTL/kill-switch).
## 9. Замечания по приёмке/тестированию
- Регресс-тест ОБЯЗАН покрывать: задача в `Done` (синхронизирована) → тик F-2 = 0 нотификаций, на enduro И orchestrator проектах (terminal не зависит от алиасинга).
- Тест НЕ должен делать реальных сетевых вызовов — мокать `list_issues_by_state` / `get_project_states` / `send_telegram` / `_dispatch` (handle_*).

View File

@@ -0,0 +1,84 @@
# Критерии приёмки — ORCH-068
Формат: каждый AC имеет чёткое условие PASS/FAIL. Задача принимается только при ВСЕХ PASS.
## Основное (P0) — livelock / спам
### AC-1. Синхронизированная done-задача → тишина
**Дано:** задача в Plane `state=Done`, БД `stage=done`, активных job нет.
**Когда:** выполняется один тик F-2 (`reconcile_plane_once` / `_reconcile_plane_project`).
- PASS: `_note_unblock` НЕ вызван; `send_telegram` НЕ вызван; `unblocked_total` не изменился; создано 0 jobs.
- FAIL: любое уведомление/лог «разблокирована»/инкремент счётчика для этой задачи.
### AC-2. Терминалы исключены из actionable-выборки
**Дано:** проект, где `Done` (и/или `Cancelled`) по UUID совпадает/«схлопнут» с `approved`/`rejected`.
**Когда:** `_reconcile_plane_project` формирует набор и обходит issues.
- PASS: issue в терминальном статусе (completed-группа / `Cancelled`) НЕ попадает ни в одну из веток `in_progress/approved/rejected`; для неё F-2 — no-op silence.
- FAIL: терминальная issue заходит в ветку approved/rejected/in_progress.
### AC-3. `_note_unblock` только при реальном state change
**Дано:** dispatch обработчика (`handle_verdict`/`handle_status_start`) фактически НЕ изменил стадию задачи (no-op, задача уже в целевом состоянии).
- PASS: `_note_unblock` НЕ вызван.
- FAIL: `_note_unblock` вызван после no-op dispatch.
### AC-4. Дедуп по неизменному состоянию
**Дано:** две последовательные итерации тика по одной и той же синхронизированной задаче, состояние между тиками не менялось.
- PASS: суммарно 0 повторных уведомлений по этой задаче.
- FAIL: повторное уведомление на втором тике без изменения состояния.
### AC-5. Нет регресса легитимной разблокировки F-2
**Дано:** задача, у которой Plane=`Approved`, а локальная стадия НЕ продвинулась (реально потерянный verdict-webhook), grace выдержан, активных job нет.
**Когда:** тик F-2.
- PASS: `handle_verdict(approved)` доигран; задача продвинута; `_note_unblock` вызван РОВНО один раз (реальный state change).
- FAIL: задача не продвинута ИЛИ нотификация не отправлена ИЛИ отправлена многократно.
### AC-6. Аналогично для in_progress-старта и rejected-отката
- PASS: потерянный `In Progress` (task is None) → старт пайплайна + 1 unblock; потерянный `Rejected` → откат + 1 unblock — оба только при реальном изменении.
- FAIL: ложный/повторный unblock или отсутствие легитимного.
## Инварианты (P0)
### AC-7. Деньги/ресурсы не тратятся на синхронизированные задачи
- PASS: 0 jobs, 0 agent_runs, 0 токенов для done/cancelled задач (как до бага).
- FAIL: любой созданный job/agent_run.
### AC-8. never-raise сохранён
**Дано:** `list_issues_by_state` / `get_project_states` / `_dispatch` / `send_telegram` бросают исключение.
- PASS: тик не падает; ошибка изолирована (per-issue / per-project), логируется; остальные задачи обрабатываются.
- FAIL: непойманное исключение роняет тик/поток.
### AC-9. Kill-switch'и работают
- PASS: `ORCH_RECONCILE_ENABLED=false` → F-1 и F-2 не выполняются; `ORCH_RECONCILE_PLANE_ENABLED=false` → F-2 не выполняется, F-1 работает.
- FAIL: любой свитч не гасит соответствующую ветку.
### AC-10. F-1 / F-3 / реестры / схема БД не изменены
- PASS: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, поведение F-1/F-3 — без изменений (diff не затрагивает).
- FAIL: любое изменение перечисленного.
### AC-11. Self-hosting безопасность
- PASS: ни один путь reconciler не рестартит/не роняет прод-контейнер `orchestrator`.
- FAIL: обратное.
## Secondary (P1) — кэш статусов
### AC-12. Устаревший `_STATES_CACHE` обновляется без рестарта
**Дано:** после старта процесса в Plane появился новый статус (его UUID отсутствует в кэше).
**Когда:** срабатывает выбранный механизм (TTL истёк / flush-on-unknown / ручной flush).
- PASS: следующий `get_project_states` возвращает свежий набор, включающий новый статус; webhook на новый статус даёт корректное pipeline-действие БЕЗ рестарта.
- FAIL: процесс продолжает отдавать устаревший набор → «no pipeline action».
### AC-13. Совместимость кэша по умолчанию
- PASS: при дефолтных настройках (TTL не задан / flush не сработал) поведение `get_project_states` не регрессирует; enduro по-прежнему получает свои UUID, fallback на `_DEFAULT_STATES` при недоступности API сохранён.
- FAIL: регресс резолва статусов или потеря fallback.
## Документация (P0 по правилам проекта)
### AC-14. Документация обновлена в том же PR
- PASS: обновлены применимые из {`docs/architecture/README.md` (Reconciler/Plane Sync), ADR, `CHANGELOG.md`, `CLAUDE.md`, `.env.example`}; reviewer подтверждает.
- FAIL: поведение изменено, доки/ADR/CHANGELOG не обновлены.
## Тесты (P0)
### AC-15. `pytest tests/ -q` зелёный
- PASS: весь набор тестов проходит; добавлены регресс-тесты из `04-test-plan.yaml`, включая done→silence на enduro и orchestrator.
- FAIL: любой красный тест или отсутствие регресс-теста на основной баг.

View File

@@ -0,0 +1,122 @@
work_item: ORCH-068
description: >
Регрессионные и модульные тесты на устранение livelock reconciler F-2
(спам _note_unblock для синхронизированной done-задачи) и связанного бага
кэша статусов. Все тесты офлайн: Plane API / Telegram / dispatch мокаются.
Целевые модули: src/reconciler.py, src/plane_sync.py.
tests:
# ---------- P0: основной баг (livelock / спам) ----------
- id: TC-01
type: unit
description: >
Синхронизированная done-задача (Plane=Done, БД=done, нет активных job):
один тик F-2 -> _note_unblock НЕ вызван, send_telegram НЕ вызван,
unblocked_total не изменился, 0 jobs. (AC-1, AC-7)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-02
type: unit
description: >
Терминал «схлопнут» с approved по UUID: issue в Done с тем же UUID, что и
approved-набор, НЕ заходит ни в одну ветку in_progress/approved/rejected
(silence). Проверка проектно-независимого исключения терминалов. (AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-03
type: unit
description: >
Cancelled терминал также исключён из actionable-выборки -> тик = silence,
0 нотификаций. (AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-04
type: unit
description: >
_note_unblock не вызывается после no-op dispatch: handle_verdict не сдвинул
стадию (задача уже в целевом состоянии) -> 0 нотификаций. (AC-3)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-05
type: unit
description: >
Дедуп: два последовательных тика по одной синхронизированной задаче без
изменения состояния -> суммарно 0 повторных уведомлений. (AC-4)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-06
type: unit
description: >
Нет регресса: Plane=Approved, локальная стадия не продвинута, grace выдержан,
нет активных job -> handle_verdict доигран, задача продвинута, _note_unblock
вызван РОВНО один раз. (AC-5)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-07
type: unit
description: >
Нет регресса для in_progress (task is None -> старт пайплайна, 1 unblock) и
rejected (task существует -> откат, 1 unblock), оба только при реальном
изменении состояния. (AC-6)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-08
type: unit
description: >
never-raise: list_issues_by_state / get_project_states / _dispatch /
send_telegram бросают исключение -> тик не падает, ошибка изолирована и
залогирована, прочие issues обработаны. (AC-8)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-09
type: unit
description: >
Kill-switch: reconcile_enabled=False -> F-2 не выполняется;
reconcile_plane_enabled=False -> F-2 не выполняется, F-1 не затронут. (AC-9)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-10
type: integration
description: >
End-to-end F-2 на двух проектах (enduro И orchestrator): задача в Done на
каждом -> тик reconcile_plane_once = 0 нотификаций / 0 jobs на обоих,
независимо от алиасинга статусов проекта. Главный регресс-тест бага. (AC-1, AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
# ---------- P1: связанный баг кэша статусов ----------
- id: TC-11
type: unit
description: >
Устаревший _STATES_CACHE обновляется без рестарта: после появления нового
статуса срабатывает выбранный механизм (TTL/flush) -> следующий
get_project_states содержит новый статус. (AC-12)
module: tests/test_plane_states_cache.py
expected: PASS
- id: TC-12
type: unit
description: >
Совместимость по умолчанию: при дефолтных настройках get_project_states не
регрессирует — enduro отдаёт свои UUID, fallback на _DEFAULT_STATES при
недоступности API сохранён. (AC-13)
module: tests/test_plane_states_cache.py
expected: PASS
# ---------- P0: общий прогон ----------
- id: TC-13
type: integration
description: >
Полный набор pytest tests/ -q зелёный (нет регресса в reconciler/plane/qg/
stage_engine). (AC-15)
module: tests/
expected: PASS

View File

@@ -0,0 +1,162 @@
# ADR-001 (ORCH-068): Исключение терминалов из F-2 по группе состояния + подтверждённый unblock + TTL кэша статусов
- **Статус:** Accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-068 (BUG: reconciler livelock — спам «разблокирована» по синхронизированной done-задаче)
- **Сквозной ADR:** уточняет [adr-0007-reconciler.md](../../../architecture/adr/adr-0007-reconciler.md) (F-2) — реестры/схема НЕ меняются
- **Связанные:** ORCH-053 (reconciler F-2), ORCH-066 (новая статусная модель Plane — триггер регрессии), ORCH-060 (F-1 пред-гарды), ORCH-10 (`get_project_states`)
## Контекст
Reconciler F-2 (`src/reconciler.py`, `_reconcile_plane_project` / `_reconcile_plane_issue`)
опрашивает Plane per-project и доигрывает потерянные webhook-переходы через штатные
`handle_status_start` / `handle_verdict`. После мерджа ORCH-066 (новая статусная модель
Plane) на проде с 22:17 UTC reconciler каждые ~120с слал в Telegram
`reconciler: ET-002 done разблокирована (потерян webhook)` для задачи ET-002, которая
полностью синхронизирована (БД `stage=done`, Plane `state=Done` с 2026-05-21). 191+
сообщений за ночь — livelock без advance/jobs/токенов, но подрывающий доверие alert-fatigue.
Диагностика (BRD §3) выявила **два независимых, складывающихся дефекта**:
- **D1 (выборка):** F-2 различает actionable-статусы по **голому UUID**
(`in_progress`/`approved`/`rejected`). `get_project_states` строит маппинг по *именам*
статусов, недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После
ORCH-066 набор имён enduro изменился → терминальный `Done` перестал однозначно
отличаться от `approved` по UUID, и ET-002 (Plane=Done) **попала** в actionable-набор
ветки `approved`. Терминальные статусы (`Done`/`Cancelled`) нигде не исключаются из F-2.
- **D2 (нотификация):** `_note_unblock` вызывается **безусловно сразу после `_dispatch`**,
не проверяя, изменил ли обработчик реально состояние задачи. `handle_verdict(approved)`
для уже-`done` задачи — no-op, но нотификация всё равно уходит. Это прямо нарушает
собственный docstring `_note_unblock` («fires only on an actual state change, never per
idle tick») и инвариант silence-when-in-sync (AC-9/AC-10 ORCH-053).
Связанный secondary-баг (BRD §4): `_STATES_CACHE` (`src/plane_sync.py`) кэширует статусы
на **весь lifetime процесса**. После появления нового Plane-статуса боевой процесс держит
устаревший набор → webhook на новый статус даёт «no pipeline action», лечилось только
рестартом орка. Примитив сброса `reload_project_states()` уже есть, но автоматически не
вызывается.
Ограничения (из ТЗ, обязаны сохраниться): источник истины F-2 — Plane (не переписываем);
НЕ трогать `STAGE_TRANSITIONS` / `QG_CHECKS` / схему БД / контракты `handle_*` / F-1 / F-3;
never-raise per unit of work; kill-switch'и; 0 jobs/0 токенов для синхронизированных задач;
self-hosting — reconciler НИКОГДА не рестартит прод-контейнер.
## Решение
Чиним **оба** дефекта независимыми слоями (defense in depth, как принято в проекте —
ORCH-058) плюс TTL для кэша. Все правки локальны в `src/reconciler.py` и
`src/plane_sync.py`; реестры, схема БД и контракты обработчиков не меняются.
### Слой D1 — исключение терминалов по ГРУППЕ состояния (TR-1, AC-2)
Различаем терминальные (`completed`/`cancelled`) и review/work-статусы по **группе
состояния Plane** (`state.group ∈ {backlog, unstarted, started, completed, cancelled}`),
а НЕ по голому UUID. Группа — авторитетный, проектно-независимый дискриминатор: она
корректно различает `Done` (completed) и `approved` (started/review) даже когда проект
«схлопывает» их по UUID после переименований.
Механика (single API fetch, без новых сетевых вызовов):
- `/states/`-ответ Plane содержит для каждого статуса поле `group`. Расширяем кэш-запись
`_STATES_CACHE` так, чтобы из ОДНОГО запроса хранить и текущий `{logical_key → uuid}`,
и `{uuid → group}`. `get_project_states` сохраняет **прежнюю сигнатуру и форму возврата**
(`{logical_key: uuid}`) — обратная совместимость (AC-13). Добавляется sibling-аксессор
`get_project_state_groups(project_id) -> dict[uuid, group]` (или эквивалент), читающий ту
же кэш-запись.
- В `_reconcile_plane_issue` ДО выбора ветки: если группа `new_state`
{`completed`, `cancelled`} → **тишина** (return, no-op). Fallback, когда группа
недоступна (API не отдал `group` / fallback на `_DEFAULT_STATES`): исключать по логическим
ключам терминалов `{states.get("done"), states.get("cancelled")}`.
Терминал-исключение применяется **per-issue** (а не сужением `wanted`-набора
`list_issues_by_state`), потому что при UUID-алиасинге терминал может физически совпадать с
actionable-UUID в `wanted` — фильтрация по UUID его не отсечёт, а проверка группы отсечёт.
### Слой D2 — `_note_unblock` только при подтверждённом state change (TR-2, AC-3)
`_note_unblock` (лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО когда диспетчеризованный
обработчик **фактически изменил состояние задачи**. Реализация — сравнение состояния
**до/после на стороне reconciler** (предпочтение ТЗ; контракты `handle_*` НЕ меняются):
- `approved`/`rejected` (task существует): захватить `stage_before` (из уже прочитанного
`task`), после `_dispatch` перечитать `get_task_by_plane_id(issue_id)``stage_after`;
`_note_unblock` только если `stage_after != stage_before`.
- `in_progress` + `task is None` (старт пайплайна): подтверждение = задача **появилась**
после dispatch (`get_task_by_plane_id` теперь не None).
No-op dispatch (задача уже в целевом состоянии) → 0 нотификаций. Восстанавливает соответствие
docstring и инвариант silence-when-in-sync.
### Слой TR-3 — дедуп нотификаций (страховка, AC-4)
In-memory best-effort guard: `{issue_id → last_unblocked_state_uuid}`. `_note_unblock` для
issue+state, уже отмеченного, подавляется. Сбрасывается при рестарте (допустимо — AC-11
ORCH-053, как `unblocked_total`/`last_unblocked`). D1+D2 закрывают основной кейс; TR-3 —
дополнительная сетка против любого будущего no-op-пути.
### Слой TR-4 — TTL кэша статусов (secondary, AC-12/AC-13)
Кэш-запись `_STATES_CACHE` хранит timestamp; `get_project_states` перезапрашивает API при
истечении `ORCH_PLANE_STATES_TTL_S`. Примитив инвалидации — существующий
`reload_project_states()` (не дублируем логику сброса). Новый флаг
`plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`):
- дефолт **300** (5 мин) — устаревший набор самозалечивается без рестарта (G5);
- `0` — отключает TTL → строго прежний lifetime-кэш (escape hatch / strict back-compat).
Fallback на `_DEFAULT_STATES` при недоступности API сохранён без изменений; TTL-перезапрос
возвращает тот же корректный набор → не регресс (AC-13).
## Альтернативы
- **Явный allowlist логических ключей терминалов (`done`/`cancelled`) без группы** —
отклонён как primary: хрупок к будущим переименованиям/добавлению completed-статусов
(`Monitoring after Deploy` и т.п.) и к UUID-алиасингу. Оставлен как **fallback**, когда
`group` недоступен.
- **Сужение `wanted`-набора в `list_issues_by_state`** — недостаточно: при UUID-алиасинге
терминал совпадает с actionable-UUID и не отсекается фильтром по UUID. Нужна проверка
группы per-issue.
- **Проброс «changed»-сигнала из `handle_*`** — отклонён: меняет контракт обработчиков
(запрещено ТЗ N2). Выбрано сравнение до/после на стороне reconciler.
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (как и в adr-0007):
трогает общий критический путь.
- **flush-on-unknown как primary для кэша** — допустимо ТЗ и дешевле, но недетерминирован
для юнит-теста (TC-11) и не лечит «тихий устаревший набор» без триггера-вебхука. Выбран
TTL (детерминированный, самозалечивающий); flush-on-unknown может быть добавлен позже как
комплемент, переиспользуя `reload_project_states`.
- **Admin-эндпоинт `POST /admin/plane-states/reload`** — отклонён в объёме (требует
ручного действия, не лечит автоматически); TTL покрывает G5 без нового API.
## Последствия
- **Плюсы:** livelock устранён двумя независимыми слоями; терминал-исключение
проектно-независимо (enduro И orchestrator), устойчиво к будущим переименованиям статусов;
`_note_unblock` снова соответствует своему контракту; устаревший кэш самозалечивается без
рестарта прода. Реестры/схема/контракты/F-1/F-3 не тронуты.
- **Минусы / плата:** один доп. accessor группы (тот же API-запрос, без новой сетевой
стоимости); TTL добавляет редкий перезапрос `/states/` (раз в 5 мин/проект); дедуп-словарь
— небольшая in-memory структура, неперсистентная (приемлемо).
- **Совместимость:** `get_project_states` форма возврата неизменна; `plane_states_ttl_s=0`
→ строго прежнее поведение кэша; `_DEFAULT_STATES`-fallback сохранён.
- **Self-hosting:** ни один путь не рестартит прод-контейнер (AC-11); правка
обязательно проходит staging-гейт (8501) перед прод-деплоем орка.
- **Наблюдаемость (опц.):** допустимо добавить в блок `reconcile` снимка `GET /queue`
счётчики `skipped_terminal` / `deduped` без ломающих изменений.
## Инварианты (подтверждение)
INV-1 источник истины F-2 = Plane — сохранён (правим маппинг/нотификацию, не концепцию).
INV-2 never-raise per-issue/-project/-tick — сохранён (новый guard в том же try-периметре).
INV-3 kill-switch'и `ORCH_RECONCILE_ENABLED` / `ORCH_RECONCILE_PLANE_ENABLED` — без изменений.
INV-4 F-1/F-3 — не тронуты. INV-5 0 jobs/0 токенов для done/cancelled — восстановлен.
INV-6 легитимная разблокировка реально-потерянного approved/in_progress — работает (D2
подтверждает реальный change, не подавляет его). INV-7 self-hosting — тик не рестартит прод.
## Объём изменений (для разработчика)
- `src/reconciler.py`: терминал-гард по группе + fallback в `_reconcile_plane_issue`;
before/after-сравнение стадии вокруг `_dispatch`; in-memory дедуп-словарь в `Reconciler`.
- `src/plane_sync.py`: кэш-запись с timestamp + `{uuid→group}`; `get_project_state_groups`;
TTL-логика в `get_project_states` (переиспользуя `reload_project_states`).
- `src/config.py`: флаг `plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`, дефолт 300).
- `.env.example` / `.env.staging`: задокументировать `ORCH_PLANE_STATES_TTL_S`.
- Доки в ТОМ ЖЕ PR: `docs/architecture/README.md` (Reconciler/Plane Sync), `CHANGELOG.md`
(`fix:`), `CLAUDE.md` (при изменении наблюдаемого поведения), этот ADR.
- Тесты: `04-test-plan.yaml` (TC-01…TC-13), офлайн (мок Plane/Telegram/`_dispatch`).

View File

@@ -0,0 +1,17 @@
# Технические риски — ORCH-068
| ID | Риск | Вероятность | Влияние | Митигация |
|----|------|-------------|---------|-----------|
| R-1 | Plane `/states/` не отдаёт поле `group` (старая версия API / урезанный ответ) → терминал-исключение по группе не срабатывает | Низкая | Высокое (рецидив livelock) | Fallback на логические ключи терминалов `{done, cancelled}` при отсутствии `group`; never-raise → консервативная тишина при сбое резолва |
| R-2 | Over-exclusion: легитимная задача в started/review-группе ошибочно классифицирована как терминал → пропущена легитимная разблокировка (регресс INV-6) | Низкая | Среднее | Исключаем ТОЛЬКО группы `completed`/`cancelled`; `approved`/`rejected` относятся к started/unstarted → не задеты; регресс-тесты TC-06/TC-07 |
| R-3 | Гонка before/after: между `stage_before` и `stage_after` живой webhook двигает стадию → ложный `_note_unblock` | Очень низкая | Низкое | active-job guard + `max_concurrency=1` уже сериализуют; дедуп TR-3 подавляет повтор; ложный unblock безвреден (0 jobs/токенов) |
| R-4 | TTL `300s` провоцирует частые `/states/`-перезапросы при многих проектах | Низкая | Низкое | 1 запрос/проект/5 мин — пренебрежимо; `ORCH_PLANE_STATES_TTL_S=0` отключает TTL |
| R-5 | TTL-перезапрос в момент недоступности Plane → временный fallback на `_DEFAULT_STATES` (enduro) для не-enduro проекта | Низкая | Среднее | Поведение идентично текущему cold-cache fallback; самозалечивается следующим успешным запросом; не хуже статус-кво |
| R-6 | Дедуп-словарь растёт неограниченно (по issue_id) | Очень низкая | Низкое | Ключи — только реально разблокированные issue (редки); сбрасывается при рестарте; при необходимости — ограничить размер/LRU |
| R-7 | Изменение в `get_project_states` (кэш-запись) ломает прочих потребителей формы возврата | Низкая | Высокое | Внешняя сигнатура и форма `{logical_key: uuid}` сохранены; группа — отдельный accessor; покрыто TC-12 (совместимость по умолчанию) |
| R-8 | Self-hosting: правка в работающем прод-инструменте | — | Высокое | Обязательный staging-гейт (8501); запрет рестарта прод-контейнера в рамках задачи; INV-7 |
## Замечания
- Все правки локальны (`reconciler.py`, `plane_sync.py`, `config.py`); схема БД, реестры
`STAGE_TRANSITIONS`/`QG_CHECKS`, контракты `handle_*`, F-1/F-3 — не затронуты (AC-10).
- Тесты офлайн (мок Plane API / Telegram / `_dispatch`) — сетевых вызовов в CI нет.

View File

@@ -0,0 +1,47 @@
---
type: review
work_item_id: ORCH-068
verdict: APPROVED
version: 1
---
# Review ORCH-068
## Summary
Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной done-задаче после ORCH-066) реализован чисто и полностью по ТЗ/ADR. Два независимых слоя (D1 терминал-исключение по группе состояния + D2 подтверждённый state change) плюс TR-3 дедуп и TR-4 TTL кэша. Правки строго локальны в `src/reconciler.py` (F-2), `src/plane_sync.py`, `src/config.py`. Запрещённые ТЗ артефакты (`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, F-1, F-3) не тронуты — diff не выходит за 3 файла `src/`. `pytest tests/ -q`**764 passed**.
## Соответствие ТЗ
- **TR-1** (исключить терминалы) ✅ — `_is_terminal_state` по `state.group ∈ {completed, cancelled}` с fallback на логические ключи `done`/`cancelled`; проверка per-issue (а не сужением `wanted`), что корректно для UUID-алиасинга.
- **TR-2** (`_note_unblock` только при реальном change) ✅ — `_stage_changed` (сравнение стадии до/после `_dispatch`), для in_progress-старта подтверждение = задача появилась; контракты `handle_*` не менялись.
- **TR-3** (дедуп) ✅ — in-memory guard `{issue_id → state_uuid}`, best-effort, сброс при рестарте (как `unblocked_total`).
- **TR-4** (TTL кэша) ✅ — `plane_states_ttl_s` (дефолт 300, `0`=lifetime), переиспользует `reload_project_states`; форма возврата `get_project_states` неизменна; при сбое перезапроса отдаётся stale-but-correct набор.
## Соответствие ADR
ADR-001 (terminal-exclusion-and-cache-ttl) реализован 1:1: группа как primary-дискриминатор, allowlist-fallback, before/after-сравнение на стороне reconciler, TTL с инвалидацией через существующий примитив. Сквозной adr-0007 дополнен корректной ссылкой. Все инварианты INV-1…INV-7 сохранены (источник истины Plane, never-raise per-issue, kill-switch'и, F-1/F-3 нетронуты, self-hosting не рестартит прод).
## Критерии приёмки
AC-1…AC-15 — все PASS. Покрытие тестами адресное: TC-01 (synced Done silence), TC-02 (aliased terminal по группе — ядро D1), TC-03 (Cancelled), TC-04 (no-op silence), TC-05 (дедуп), TC-06/TC-07 (легитимный unblock ×1), TC-08 (never-raise изоляция), TC-09 (kill-switch), TC-10 (enduro И orchestrator — headline-регресс), TC-11/TC-12 (TTL self-heal + back-compat + stale-on-failure).
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- [ ] Дедуп-guard ключуется по `issue_id → state_uuid` без сброса при смене состояния. Теоретический edge-case: задача legitимно проходит `approved`(uuid_X)→…→снова `approved`(тот же uuid_X) — повторное (но легитимное) уведомление будет подавлено. Функционального ущерба нет (advance выполняется в `_dispatch` независимо от нотификации), это потеря только уведомления, и D2 — основной гард. Best-effort по контракту ТЗ. Можно при желании чистить запись при детекте смены состояния away-and-back. Не блокирует.
## Документация
Обновлена полно и в том же PR (AC-14 PASS):
- `docs/architecture/README.md` — компонент **Plane Sync** (TTL + `{uuid→group}`) и секция **Reconciler/F-2/F-4** (терминал-исключение, дедуп, счётчики `skipped_terminal_total`/`deduped_total`); футер «обновлять при изменении» расширен записью ORCH-068.
- `docs/architecture/adr/adr-0007-reconciler.md` — добавлена кросс-ссылка на per-WI ADR.
- `docs/work-items/ORCH-068/06-adr/ADR-001-…` — детальный ADR (Accepted).
- `CHANGELOG.md` — запись `### Fixed` (D1/D2/TR-3/TR-4, инварианты, тесты).
- `.env.example``ORCH_PLANE_STATES_TTL_S=300` с комментарием.
Изменение `src/` сопровождено соответствующим обновлением документации — требование golden-source выполнено.

View File

@@ -0,0 +1,64 @@
---
type: test-report
work_item_id: ORCH-068
result: PASS
---
# Test Report — ORCH-068
Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной
done-задаче) + связанный баг устаревшего `_STATES_CACHE`.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8)
- Среда исполнения: worktree `feature/ORCH-068-bug-reconciler-livelock-unbloc`
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK (read-only smoke)
- Дата: 2026-06-08T05:13:59Z
## Smoke test API (read-only, прод 8500 не трогался деструктивно)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok",...}` — OK |
| `GET /status` | 200, активные задачи отданы — OK |
| `GET /queue` | 200, counts + блок `reconcile` (enabled/plane_enabled/unblocked_total) — OK |
## Результаты
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | Синхронизированная done → тишина (AC-1, AC-7) | `test_tc01_synced_done_is_silent` | PASS |
| TC-02 | Терминал «схлопнут» с approved по UUID исключён (AC-2) | `test_tc02_terminal_aliased_to_approved_excluded` | PASS |
| TC-03 | Cancelled терминал исключён (AC-2) | `test_tc03_cancelled_excluded` | PASS |
| TC-04 | `_note_unblock` не вызван после no-op dispatch (AC-3) | `test_tc04_noop_dispatch_no_unblock` | PASS |
| TC-05 | Дедуп: 0 повторных уведомлений (AC-4) | `test_tc05_dedup_no_repeat_notification` | PASS |
| TC-06 | Легитимный approved → unblock ровно один раз (AC-5) | `test_tc06_legit_approved_unblock_once` | PASS |
| TC-07 | in_progress-старт и rejected-откат, каждый 1 unblock (AC-6) | `test_tc07_in_progress_start_and_rejected_each_one_unblock` | PASS |
| TC-08 | never-raise: изоляция ошибок (AC-8) | `test_tc08_never_raise_isolation` | PASS |
| TC-09 | Kill-switch'и F-2 (AC-9) | `test_tc09_kill_switches` | PASS |
| TC-10 | done→silence на enduro И orchestrator (headline-регресс, AC-1/AC-2) | `test_tc10_done_silent_on_all_projects` | PASS |
| TC-11 | Устаревший `_STATES_CACHE` self-heal по TTL (AC-12) | `test_tc11_stale_cache_refreshes_after_ttl` + accessor/zero-ttl тесты | PASS |
| TC-12 | Совместимость кэша по умолчанию + fallback (AC-13) | `test_tc12_enduro_uuids_unchanged`, `test_tc12_api_error_falls_back_to_defaults`, `test_tc12_stale_served_when_refresh_fails` | PASS |
| TC-13 | Полный прогон `pytest tests/` зелёный (AC-15) | весь набор | PASS |
## Соответствие критериям приёмки
AC-1…AC-15 — все PASS. Целевые регресс-тесты (TC-01..TC-12) проходят;
полный набор без регрессий в reconciler / plane / qg / stage_engine / webhooks.
## Вывод pytest
### Целевые файлы (tests/test_reconciler_plane.py + tests/test_plane_states_cache.py)
```
collected 26 items
... (все 26 PASSED, включая tc01..tc17 reconciler + tc11/tc12 cache)
======================== 26 passed, 1 warning in 0.82s =========================
```
### Полный набор
```
======================= 764 passed, 1 warning in 13.66s ========================
```
(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-068, предсуществующий.)
## Итог
PASS

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-068
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,33 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T05:17:46Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Exit code 0 → SUCCESS.
Executed canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001),
so the B6 registry-isolation check read the staging instance's own process-env
(`.env.staging` → SANDBOX-only registry):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
## Result: 8/10 checks PASS
- REAL failed: none
- All REAL checks green (Block A SMOKE, Block B ACCESS incl. B6 registry isolation,
C7 create issue, C8 trigger pipeline).
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
Per ORCH-061, the two infra-only checks C9a/C9b (which depend on SANDBOX bot accounts
being project members, not on the pipeline) are tolerated when every REAL check is
green; the script prints `INFRA-WAIVED:`/`VERDICT:` lines and exits 0. Verdict trusts
the exit code.

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-068
window_s: 900
checks_total: 30
checks_failed: 0
---
# Post-deploy log — ORCH-021 post-deploy monitor
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.

View File

@@ -0,0 +1,7 @@
# Business Request: CRIT BUG: фантомный merge — деплой без слияния в main
Work Item ID: ORCH-071
## Description
TBD

View File

@@ -0,0 +1,53 @@
# BRD — ORCH-071: Фантомный merge — деплой без слияния в main
## 1. Контекст и тип
- **Тип:** BUG CRITICAL (целостность `main` / надёжность деплоя, self-hosting).
- **Обнаружено:** Слава + Стрим, 2026-06-08, при разборе «ORCH-067 не подхватился».
- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md`.
- **Подозрение на регресс:** ORCH-065 (idempotent merge / lease-reclaim) — последний честный merge (PR#66).
- **Связано:** восстановление текущего `main` ведётся ОТДЕЛЬНО (ветка `integ/restore-main-2026-06-08`); эта задача — ROOT-FIX, чтобы фантом не повторялся.
## 2. Проблема (бизнес-формулировка)
Self-deploy (Phase B) для self-hosting репо `orchestrator` собирает прод-образ из ВЕТКИ задачи и рапортует `finalize SUCCESS` + post-deploy `HEALTHY`, **но git-merge ветки в `main` НЕ происходит**. PR остаётся `open`. Следующая задача срезает свою ветку от устаревшего `main` → теряет код незалитых предшественников.
Накопительно потеряны в `main`: **ORCH-022, 059, 066, 068** (PR#67/68/69/70 — open). Последний реально слитый — ORCH-065 (PR#66).
## 3. Подтверждённый root cause (по результатам код-аудита)
Гипотеза A постмортема подтверждена аудитом кода ветки:
1. **В `src/` НЕТ кода, выполняющего merge PR в `main`** (`grep` по `pulls/.../merge`, `/merge`, `merge_pr` — 0 совпадений). Фактический merge выполняет ТОЛЬКО LLM-агент `deployer` через Bash в начале стадии `deploy` (см. `.openclaw/agents/deployer.md`).
2. Для self-hosting (`orchestrator`) стадия `deploy` оркеструется **детерминированным кодом** (`stage_engine._handle_self_deploy_phase_b``self_deploy.initiate_deploy` → finalizer `run_deploy_finalizer`), и агент `deployer` **НЕ запускается** (так предписывает `deployer.md`). Detached host-процесс делает retag staging-образа на прод-тег + рестарт 8500. **Ни одна фаза A/B/C не вызывает merge ветки в `main`.**
3. `run_deploy_finalizer` маппит exit-code хука `0→SUCCESS`, пишет `14-deploy-log.md` и вызывает `advance_stage(..., finished_agent="deployer")`. Гейт `check_deploy_status` читает только `deploy_status:` из артефакта → `SUCCESS → done`. **Состояние `main` нигде не верифицируется.**
Итог: для self-hosting путь `deploy` структурно НЕ содержит шага merge-в-main, а `done` достигается исключительно по deploy-маркеру. «Зелёный» деплой + здоровый прод (образ из рабочей ветки) маскируют отсутствие merge — сигнала о проблеме нет, пока следующая задача не потеряет код предшественника.
Вторичный фактор (усиливает риск даже если merge добавить наивно): Phase B **рестартит прод-контейнер**, поэтому любой держатель merge-lease / незавершённый git-шаг внутри процесса умирает до завершения merge (урок №3 постмортема).
## 4. Бизнес-цели
| ID | Цель |
|----|------|
| **G1** | Деплой ВЕРИФИЦИРУЕТ, что задеплоенный commit реально влит в `main` ПОСЛЕ деплоя (deployed SHA — предок `origin/main` ИЛИ `PR.merged==true`). Иначе — alert, задача НЕ `done`. |
| **G2** | Задача → `done` ТОЛЬКО при подтверждённом merge (`PR.merged==true`); маркеров `finalize`/`post-deploy` недостаточно. |
| **G3** | Merge в `main` завершается и подтверждается ДО рестарта прод-контейнера, ЛИБО merge вынесен в шаг, переживающий рестарт (паттерн `requeue_running_jobs` для merge-в-main). |
| **G4** | Диагностический runbook (4 проверки из постмортема) — в `docs/operations`. |
## 5. Не-цели
- Не менять source-of-truth (Plane), схему БД.
- Не отменять self-hosting safety (no auto-rollback / no-restart-others) — наоборот, усилить верификацией.
- Восстановление текущего `main` (долив 022/059/066/068) — ОТДЕЛЬНАЯ ветка `integ/restore-main-2026-06-08`, вне scope.
## 6. Инварианты (обязательны к соблюдению)
| ID | Инвариант |
|----|-----------|
| **INV-1** | **never-raise** на шаге верификации — при ошибке шлётся alert, не падение процесса/конвейера. |
| **INV-2** | self-hosting safety: верификация НЕ рестартит и НЕ роняет прод-контейнер `orchestrator` (8500), не трогает другие проекты. |
| **INV-3** | Ручной approve прод-деплоя (триггер «Confirm Deploy», ORCH-059) сохранён — новая логика не вводит авто-деплой. |
| **INV-4** | Никогда не делать force-push / прямой push в `main`; merge только через PR-merge API Gitea (как у deployer-агента сегодня). |
| **INV-5** | Идемпотентность: повторный прогон (re-drive/reaper/двойной webhook) не делает второй merge и не ломает контракты (опора на `pr_already_merged`, ORCH-065). |
## 7. Заинтересованные стороны
- **Owner** — одобряет прод-деплой («Confirm Deploy»), получает alert при «deployed but not merged».
- **Все проекты на инстансе** (enduro-trails) — косвенно: целостность `main` орка влияет на инструмент, обслуживающий их из общей БД/очереди.
## 8. Критерий успеха (бизнес-уровень)
После доработки невозможно состояние «задача `done` + прод задеплоен, а PR `open` / commit не в `main`»: либо merge подтверждён и задача `done`, либо задача НЕ `done` и поднят alert «deploy succeeded but not merged». Воспроизведение исходного сценария на staging показывает, что `main` реально получает commit.

View File

@@ -0,0 +1,78 @@
# ТЗ — ORCH-071: Верификация merge-в-main как условие done
> Документ фиксирует ТРЕБОВАНИЯ к изменениям (WHAT). Конкретный дизайн (HOW: новый
> leaf-модуль vs расширение существующего, где разместить шаг merge, формат
> sentinel'ов) — за архитектором (ADR `06-adr/`). ТЗ задаёт инварианты, точки
> врезки и контракты, которые дизайн обязан удовлетворить.
## 0. Резюме root cause (вход для дизайна)
Для self-hosting (`orchestrator`) стадия `deploy` идёт детерминированным путём
`_handle_self_deploy_phase_b → initiate_deploy → run_deploy_finalizer`, который
**не содержит шага merge PR в `main`** (merge делает только LLM-`deployer`, не
запускаемый на self-hosting). `done` достигается по `deploy_status: SUCCESS` без
верификации `main`. Требуется: (A) выполнить/докатить merge в `main` детерминированно
до перехода в `done`; (B) верифицировать факт merge ПОСЛЕ деплоя; (C) запретить
`done` без подтверждённого merge.
## 1. Задействованные модули `src/`
| Модуль | Роль в фиксе | Характер изменения |
|--------|--------------|--------------------|
| `src/stage_engine.py` | `run_deploy_finalizer` (Phase C), терминал-блок `next_stage == "done"`, `_handle_self_deploy_phase_b` | Врезка шага merge-в-main + пост-merge верификация; блокировка перехода в `done` при неподтверждённом merge. |
| `src/merge_gate.py` | Уже содержит `pr_already_merged` (ORCH-065, read-only guard) | Добавить детерминированный **merge-актор** для self-hosting (выполнить merge PR через Gitea API) + helper верификации «SHA предок `origin/main`». Опора на существующие `pid_alive`/`reclaim_stale_lease`. |
| `src/self_deploy.py` | Sentinel-state Phase A/B/C | Возможный новый sentinel-маркер `merged` (restart-safe), если дизайн выносит merge в отдельный переживающий рестарт шаг (G3). |
| `src/qg/checks.py` | Реестр `QG_CHECKS`, `check_deploy_status` | Возможный новый под-чек верификации merge (например `check_merged_to_main`) ЛИБО усиление условия перехода `deploy→done`. `check_deploy_status` НЕ менять по контракту парсинга. |
| `src/config.py` | Флаги | Новый kill-switch (напр. `merge_verify_enabled` / `merge_verify_repos`), таймауты merge/verify. Дефолт — область self-hosting (как ORCH-35/43/58). |
| `.openclaw/agents/deployer.md` | Промпт deployer'а (non-self merge) | Уточнить: для self-hosting merge выполняет детерминированный код; non-self путь без изменений. |
| `src/main.py` (`/queue`) | Наблюдаемость | Опционально: блок/счётчики верификации merge (`merge_verified_total`, `not_merged_alerts_total`). |
## 2. Функциональные требования
### FR-1 (G3) — Детерминированный merge-в-main для self-hosting
- Для self-hosting репо merge PR ветки в `main` ДОЛЖЕН выполняться **детерминированным кодом** (не LLM-агентом), т.к. `deployer`-агент на self-hosting `deploy` не запускается.
- Merge выполняется через **Gitea PR-merge API** (как сегодня делает агент), НИКОГДА не force-push / не прямой push в `main` (INV-4).
- ПЕРЕД merge консультироваться `merge_gate.pr_already_merged(repo, branch)` — уже слит → no-op (INV-5, переиспользовать ORCH-065).
- **G3 — порядок относительно рестарта:** merge ДОЛЖЕН быть завершён и подтверждён ДО рестарта прод-контейнера, ЛИБО вынесен в шаг, переживающий рестарт (паттерн `requeue_running_jobs`/finalizer-defer): если процесс умер во время Phase B, шаг merge докатывается после рестарта (re-drive finalizer'а или отдельный merge-job). Дизайн выбирает один из двух вариантов; выбранный обязан быть restart-safe (sentinel/jobs, без миграции БД — §4).
### FR-2 (G1) — Пост-деплой верификация merge
- ПОСЛЕ деплоя (в Phase C / финализации, ДО фиксации `done`) выполнить детерминированную верификацию: задеплоенный commit (validated SHA) — **предок `origin/main`** (`git merge-base --is-ancestor <sha> origin/main`) **ИЛИ** `PR.merged == true` (Gitea API).
- Верификация **never-raise** (INV-1): любая ошибка git/HTTP → трактуется как «не подтверждено» → alert, НЕ падение.
- При неподтверждённой верификации — **alert** «deploy succeeded but not merged» (Telegram + Plane-коммент) и задача **НЕ переходит в `done`** (FR-3).
### FR-3 (G2) — `done` только при подтверждённом merge
- Переход `deploy → done` для self-hosting ДОЛЖЕН быть обусловлен подтверждённым merge (verify из FR-2 зелёный). Наличие `deploy_status: SUCCESS` + post-deploy `HEALTHY`**недостаточно**.
- При `SUCCESS`-маркере деплоя, но неподтверждённом merge: задача удерживается (не `done`), Plane-статус — не терминальный (например текущий `Deploying`/`Awaiting` или `Blocked` по решению дизайна), шлётся alert. Конвейер НЕ откатывается на `development` автоматически из-за not-merged (это инфраструктурный, не код-дефект) — реакция = alert + ручное вмешательство (согласовать с дизайном; по умолчанию ALERT-only, как ORCH-021 self-hosting).
### FR-4 (G4) — Диагностический runbook
- В `docs/operations/` добавить runbook с 4 проверками из постмортема (метод однозначной локализации фантома):
1. Gitea API: список PR + флаги `merged`.
2. md5 прод-файлов vs `git show origin/main:<file>`.
3. `git merge-base` ветки vs `main`.
4. Таймлайн деплой-логов.
- Включить готовые команды (copy-paste) и критерий «фантом подтверждён».
### FR-5 — Условность раската (как ORCH-35/43/58)
- Новая логика merge+verify реальна для self-hosting (`is_self_hosting_repo` / `merge_verify_repos`); прочие репо — поведение БЕЗ изменений (non-self merge остаётся за агентом `deployer`).
- Kill-switch (env, дефолт `true`) → `false` восстанавливает строго прежнее поведение.
## 3. Изменения API
- **Внешний HTTP API сервиса (`/health`, `/status`, `/queue`, `/webhook/*`) — без новых endpoint'ов.** Допустимо обогащение ответа `GET /queue` блоком наблюдаемости merge-verify (счётчики), по образцу блоков `reaper`/`post_deploy`.
- **Gitea API (исходящие вызовы):** новый детерминированный вызов `POST /repos/{owner}/{repo}/pulls/{index}/merge` (merge-актор, FR-1) + чтение `GET /repos/{owner}/{repo}/pulls?...` (уже используется в `pr_already_merged`). Через существующий httpx-клиент и `settings.gitea_*`.
## 4. Изменения схемы БД
- **НЕТ.** Schema-changes запрещены (не-цель). Restart-safe состояние нового шага merge — через sentinel-файлы (`.deploy-state-<repo>/<wi>/`, как ORCH-036) и/или существующую очередь `jobs` (finalizer-defer). Колонка `jobs.pid` (ORCH-065) уже есть, при необходимости переиспользуется.
## 5. Требования к новым QG checks
- Допускается ввести детерминированный под-чек верификации merge (напр. `check_merged_to_main`), регистрируемый в `QG_CHECKS`, ЛИБО встроить верификацию как условие в логику перехода `deploy→done` без нового чека — на усмотрение дизайна. В любом случае:
- Контракт `check_deploy_status` / `_parse_deploy_status` (читает только `deploy_status:` frontmatter) **НЕ меняется**.
- `STAGE_TRANSITIONS` **НЕ меняется** (verify — это условие/под-гейт ребра/финализации, не новая стадия).
- Вердикт (если артефакт) — строго YAML-frontmatter (канон гейтов), never проза.
## 6. Артефакты, создаваемые/обновляемые по pipeline
- `14-deploy-log.md` — существующий; дизайн может добавить поле статуса merge (напр. `merged_to_main: true|false`) во frontmatter (машиночитаемо), не ломая `deploy_status:`.
- Новый runbook в `docs/operations/` (FR-4).
- **Обязательно (CLAUDE.md §2):** обновить `docs/architecture/README.md` (раздел Phase B / merge-gate / executable self-deploy — описать новый merge+verify шаг), `CHANGELOG.md`, при сквозном решении — ADR (`docs/work-items/ORCH-071/06-adr/ADR-001-*.md` и/или global `docs/architecture/adr/`).
## 7. Совместимость / регресс
- Happy-path не-self репо (enduro-trails): merge остаётся за агентом `deployer` → поведение без изменений.
- Happy-path self-hosting: при штатном merge задача `done` ставится как раньше (после добавления verify, который зелёный).
- Все существующие контракты неизменны: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (кроме возможного нового under-чека), `check_deploy_status`, БАГ-8, terminal-sync, merge-gate (ORCH-043), `Confirm Deploy` (ORCH-059), exit-коды хука (0/1/2), схема БД.

View File

@@ -0,0 +1,61 @@
# Критерии приёмки — ORCH-071
Формат: каждый критерий имеет явное условие PASS/FAIL. Машинные вердикты — из артефактов/состояния, не из прозы.
## AC-1 (G1) — Пост-деплой верификация: not-merged ⇒ не done + alert
- **Условие:** после Phase B/финализации, если задеплоенный commit НЕ влит в `origin/main` (не предок `origin/main` И `PR.merged != true`).
- **PASS:** задача НЕ переходит в `done`; шлётся alert «deploy succeeded but not merged» (Telegram + Plane-коммент).
- **FAIL:** задача стала `done` при неслитом PR ИЛИ alert не отправлен.
## AC-2 (G2) — done только при PR.merged==true (mock-тест)
- **Условие:** SUCCESS-маркеры деплоя присутствуют (`deploy_status: SUCCESS`), но PR `open` (`merged=false`).
- **PASS:** переход в `done` НЕ выполняется (тест на mock Gitea: PR open → done не ставится).
- **FAIL:** задача переведена в `done`.
## AC-3 (G3) — Merge подтверждён до/независимо от рестарта (smoke)
- **Условие:** симулирован рестарт контейнера во время Phase B (процесс/держатель merge умер до завершения merge).
- **PASS:** после рестарта merge докатывается (re-drive finalizer / merge-job, как `requeue_running_jobs`), `main` получает commit, верификация зелёная → задача `done`.
- **FAIL:** после рестарта merge не докатился, задача `done` без merge ИЛИ навсегда зависла без alert.
## AC-4 (регресс) — Happy-path
- **Условие:** merge прошёл штатно, `PR.merged==true`, deploy `SUCCESS`, верификация зелёная.
- **PASS:** `done` ставится как раньше (терминал-sync/Plane-статус как сегодня для self-hosting), без лишних alert.
- **FAIL:** регрессия — happy-path не доходит до `done` или шлёт ложный not-merged alert.
## AC-4b (регресс) — non-self репо без изменений
- **Условие:** деплой репо enduro-trails (не self-hosting).
- **PASS:** merge выполняет агент `deployer` (прежний путь), новая детерминированная merge/verify-логика — no-op для не-self.
- **FAIL:** изменилось поведение non-self деплоя.
## AC-5 — Зелёный pytest + документация
- **PASS:** `pytest tests/ -q` зелёный; обновлены `CHANGELOG.md`, `docs/architecture/README.md` (раздел Phase B / merge-verify) и runbook (`docs/operations/`).
- **FAIL:** красные тесты ИЛИ документация/CHANGELOG/runbook не обновлены (reviewer → REQUEST_CHANGES, CLAUDE.md §6).
## AC-6 — Воспроизведение исходного сценария на staging
- **Условие:** на staging провести задачу до деплоя.
- **PASS:** проверить (методом runbook), что `main` реально получил commit задачи (PR merged / SHA предок `origin/main`).
- **FAIL:** прод/«done» достигнуты, а `main` не получил commit.
## AC-7 (INV-1) — never-raise на верификации
- **Условие:** verify сталкивается с ошибкой git/HTTP (Gitea недоступна, битый ref).
- **PASS:** функция возвращает «не подтверждено» → alert, процесс/конвейер НЕ падает (исключение не пробрасывается).
- **FAIL:** исключение из verify валит finalizer/advance_stage.
## AC-8 (INV-2) — self-hosting safety
- **Условие:** шаг верификации/merge исполняется для `orchestrator`.
- **PASS:** verify/merge НЕ рестартят и НЕ роняют прод-контейнер 8500, не трогают другие проекты; merge — только PR-merge API, без push в `main`.
- **FAIL:** verify/merge перезапускает прод ИЛИ делает прямой/force push в `main`.
## AC-9 (INV-5) — идемпотентность повторного прогона
- **Условие:** re-drive стадии `deploy` / повторный webhook / reaper-requeue при уже слитом PR.
- **PASS:** `pr_already_merged` → merge не повторяется (no-op), верификация зелёная, нет дубль-merge/ошибки Gitea, нет ложного БАГ-8 отката.
- **FAIL:** второй merge / merge-error / ложный откат.
## AC-10 (FR-5) — kill-switch
- **Условие:** kill-switch новой merge/verify-логики выключен (`false`).
- **PASS:** строго прежнее поведение (1:1 до фикса).
- **FAIL:** при выключенном флаге логика всё равно срабатывает.
## AC-11 (INV-3) — ручной approve сохранён
- **PASS:** прод-деплой по-прежнему запускается только статусом «Confirm Deploy» (ORCH-059); merge/verify не вводят авто-деплой.
- **FAIL:** деплой/merge запускается без человеческого триггера.

View File

@@ -0,0 +1,103 @@
work_item: ORCH-071
title: "Верификация merge-в-main как условие done (фантомный merge)"
notes: >
Тесты детерминированные, без LLM. Gitea/PR-состояние и git-операции мокаются
(monkeypatch httpx / subprocess / merge_gate helpers). Цель — закрыть AC-1..AC-11.
Все новые функции верификации/merge соблюдают never-raise.
tests:
# --- FR-2 / G1 / AC-1: пост-деплой верификация merge ---
- id: TC-01
type: unit
description: "verify_merged_to_main возвращает True, когда deployed SHA — предок origin/main (git merge-base --is-ancestor rc=0)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-02
type: unit
description: "verify_merged_to_main возвращает True, когда PR.merged==true (Gitea mock), даже если git-проверка недоступна"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-03
type: unit
description: "verify_merged_to_main возвращает False, когда SHA не предок origin/main И PR.merged==false (фантом)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-04
type: unit
description: "never-raise (AC-7): ошибка git/HTTP в verify -> False (не подтверждено), исключение не пробрасывается"
module: tests/test_merge_verify.py
expected: PASS
# --- FR-3 / G2 / AC-2: done только при подтверждённом merge ---
- id: TC-05
type: integration
description: "Phase C finalizer: deploy_status=SUCCESS но PR open -> задача НЕ переходит в done, шлётся alert 'deploy succeeded but not merged'"
module: tests/test_deploy_finalizer_merge_gate.py
expected: PASS
- id: TC-06
type: integration
description: "Phase C finalizer: deploy_status=SUCCESS и merge подтверждён -> задача переходит в done (happy-path, AC-4)"
module: tests/test_deploy_finalizer_merge_gate.py
expected: PASS
# --- FR-1 / AC-9: детерминированный merge-актор + идемпотентность ---
- id: TC-07
type: unit
description: "merge-актор self-hosting вызывает Gitea POST /pulls/{index}/merge, когда PR не слит; никакого push/force-push в main"
module: tests/test_merge_actor.py
expected: PASS
- id: TC-08
type: unit
description: "идемпотентность (AC-9): pr_already_merged==True -> merge-актор no-op (нет второго merge, нет ошибки Gitea)"
module: tests/test_merge_actor.py
expected: PASS
- id: TC-09
type: unit
description: "merge-актор never-raise: ошибка Gitea API -> (False, reason), исключение не пробрасывается"
module: tests/test_merge_actor.py
expected: PASS
# --- FR-1 G3 / AC-3: merge переживает рестарт ---
- id: TC-10
type: integration
description: "smoke (AC-3): симуляция смерти процесса во время Phase B -> re-drive finalizer/merge-job докатывает merge после 'рестарта', main получает commit, verify зелёная -> done"
module: tests/test_deploy_restart_merge_recovery.py
expected: PASS
# --- FR-5 / AC-10: условность раската ---
- id: TC-11
type: unit
description: "AC-4b: для non-self репо (enduro-trails) новая merge/verify-логика = no-op (merge остаётся за агентом deployer)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-12
type: unit
description: "AC-10: kill-switch выключен -> строго прежнее поведение (verify/merge не выполняются)"
module: tests/test_merge_verify.py
expected: PASS
# --- INV-2 / AC-8: self-hosting safety ---
- id: TC-13
type: unit
description: "AC-8: путь merge/verify не вызывает рестарт прод-контейнера и не делает прямой/force push в main (проверка отсутствия соответствующих вызовов)"
module: tests/test_merge_actor.py
expected: PASS
# --- INV-3 / AC-11: ручной approve сохранён ---
- id: TC-14
type: integration
description: "AC-11: Phase B запускается только при confirm_deploy=True ('Confirm Deploy'); merge/verify не вводят авто-деплой (обычный Approved -> no-op)"
module: tests/test_deploy_finalizer_merge_gate.py
expected: PASS
# --- Регресс существующих контрактов ---
- id: TC-15
type: unit
description: "регресс: check_deploy_status / _parse_deploy_status неизменны (читают только deploy_status: frontmatter)"
module: tests/test_qg_checks.py
expected: PASS
- id: TC-16
type: unit
description: "регресс: STAGE_TRANSITIONS и реестр QG_CHECKS не сломаны (deploy->done ребро на месте)"
module: tests/test_stages.py
expected: PASS

View File

@@ -0,0 +1,186 @@
# ADR-001 (ORCH-071): Детерминированный merge-в-main + пост-деплой верификация как условие `done`
## Статус
Accepted
## Контекст
### Подтверждённый root cause (постмортем `docs/history/LESSONS_2026-06-08_phantom-merge.md`)
Для self-hosting репо `orchestrator` стадия `deploy` идёт **детерминированным** путём
`stage_engine._handle_self_deploy_phase_b → self_deploy.initiate_deploy →
run_deploy_finalizer`, а LLM-агент `deployer` **не запускается** (так предписывает
`.openclaw/agents/deployer.md`). Фактический merge PR в `main` исторически выполнял
**только** агент `deployer` через Bash/curl. Следствие: на self-hosting пути **нет ни
одного шага, выполняющего git-merge ветки в `main`** (аудит: `grep` по
`pulls/.../merge` в `src/` — 0 совпадений).
Detached host-процесс (Phase B) лишь **retag staging-образа на прод-тег + рестарт 8500**.
`run_deploy_finalizer` маппит exit-code хука `0 → SUCCESS`, пишет `14-deploy-log.md`,
вызывает `advance_stage(..., finished_agent="deployer")`; гейт `check_deploy_status`
читает только `deploy_status:``SUCCESS → done`. **Состояние `main` нигде не
верифицируется.** «Зелёный» деплой (прод-образ собран из рабочей ветки) маскирует
отсутствие merge — сигнала нет, пока следующая задача не срежет ветку от устаревшего
`main` и не потеряет код предшественника. Накопительно потеряны ORCH-022/059/066/068.
Вторичный фактор (урок №3): Phase B **рестартит прод-контейнер**, поэтому любой
держатель merge-lease / незавершённый git-шаг ВНУТРИ живого процесса умирает до
завершения merge. Значит наивно «добавить merge в Phase B» (живой старый контейнер,
который вот-вот рестартует) — снова гонка с рестартом.
### Требования (из ТЗ/BRD)
- **G1/FR-2** — пост-деплой верификация: deployed SHA — предок `origin/main` ИЛИ `PR.merged==true`.
- **G2/FR-3** — `done` ТОЛЬКО при подтверждённом merge; `deploy_status: SUCCESS` + post-deploy `HEALTHY` — недостаточно.
- **G3/FR-1** — merge детерминированным кодом (агент не запускается), через Gitea PR-merge API; завершён ДО рестарта ЛИБО вынесен в шаг, переживающий рестарт.
- **INV-1** never-raise; **INV-2** не рестартить/не ронять прод; **INV-3** ручной approve сохранён; **INV-4** только PR-merge API, никогда push/force-push в `main`; **INV-5** идемпотентность (`pr_already_merged`).
- **НЕ менять:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, схему БД, source-of-truth.
## Решение
Вводим **детерминированный merge-актор + пост-merge верификацию** как **под-гейт ребра
`deploy → done`**, врезанный в `advance_stage`. Это симметрично существующим edge-под-гейтам
(security/merge-gate/image-freshness на ребре `deploy-staging → deploy`): `STAGE_TRANSITIONS`
не меняется, новый под-гейт — условие финализации, а не новая стадия.
### D1. Точка врезки — `advance_stage`, ребро `deploy → done` (единая для ВСЕХ путей)
Врезка `_handle_merge_verify(...)` в `src/stage_engine.py::advance_stage` **после** успешного
прохождения QG (`check_deploy_status == SUCCESS`, т.е. `next_stage == "done"`) и **до**
`update_task_stage(task_id, next_stage)`:
```python
# --- ORCH-071 merge-verify under-gate (deploy -> done edge) ---
if current_stage == "deploy" and next_stage == "done":
if _handle_merge_verify(task_id, repo, work_item_id, branch, result):
return result # HOLD: merge не подтверждён -> alert, НЕ done, НЕ rollback
```
`advance_stage`**единственная** функция перехода стадий. Её вызывают `run_deploy_finalizer`
(Phase C), reconciler F-1 (`finished_agent=None`), job-reaper (re-drive). Врезка именно здесь
**гейтит ВСЕ пути единообразно**: ни один из них не сможет довести `deploy → done` без
подтверждённого merge. Это закрывает скрытую дыру: reconciler F-1 предоценивает
`check_deploy_status` read-only и при зелёном вызывает `advance_stage` — без врезки он бы
протолкнул `done` в обход merge.
### D2. Когда выполняется merge — в Phase C (после рестарта), а НЕ в Phase B
Merge выполняется внутри `_handle_merge_verify`, т.е. на ребре `deploy → done`, которое
достигается **из `run_deploy_finalizer` уже в НОВОМ контейнере после рестарта прода**. Это
осознанный выбор в пользу второго варианта G3 («шаг, переживающий рестарт»):
- Phase B лишь **диспетчеризует** detached-деплой (`ssh` возвращается мгновенно), рестарт прода
происходит асинхронно на хосте. Merge в Phase B (живой старый контейнер) **гонялся бы** с
рестартом и мог быть убит на полушаге — ровно постмортем-урок №3. Поэтому merge в Phase B
**отвергнут**.
- Phase C finalizer уже **restart-surviving**: это reserved-agent job `deploy-finalizer`,
переставляемый с defer и **claim'ится воркером нового контейнера** после рестарта; если новый
контейнер умрёт на полушаге merge — job re-drive'ится (reaper/requeue), а `pr_already_merged`
делает повтор идемпотентным. Merge физически происходит **строго ПОСЛЕ** рестарта → рестарт
его не убивает. G3 удовлетворён.
### D3. Merge-актор — `src/merge_gate.py::merge_pr(repo, branch) -> (bool, str)`
Новый детерминированный merge-актор (рядом с `pr_already_merged`/`pid_alive`/`reclaim_stale_lease`):
1. `pr_already_merged(repo, branch)``True`**no-op** `(True, "already-merged")` (INV-5/AC-9).
2. Иначе `GET /repos/{owner}/{repo}/pulls?state=open&head=<branch>` → индекс открытого PR.
3. `POST /repos/{owner}/{repo}/pulls/{index}/merge` (Do: `merge`) через существующий httpx-клиент
и `settings.gitea_*`. Никогда не push/force-push в `main` (INV-4/AC-8).
4. **never-raise** (INV-1): любая HTTP/parse-ошибка → `(False, reason)`; нет открытого PR при
`pr_already_merged==False``(False, "no open PR")`.
Работает под merge-lease, который уже **удерживается** этой задачей с merge-gate ребра
`deploy-staging → deploy` (Phase A held-across-wait) и освобождается на `done`/откате
(существующий `release_merge_lease`, ORCH-043) либо реклеймится по смерти держателя (ORCH-065).
Сериализация слияний сохранена без новой блокировки.
### D4. Верификатор — `src/merge_gate.py::verify_merged_to_main(repo, branch, sha) -> bool`
Возвращает `True`, если merge подтверждён (FR-2):
- `pr_already_merged(repo, branch) is True` **ИЛИ**
- `git merge-base --is-ancestor <sha> origin/main` в worktree задачи (после `git fetch origin main`),
где `<sha>` — validated commit = `git rev-parse HEAD` worktree (тот же якорь, что
`image_freshness.validated_revision`).
**never-raise** (INV-1/AC-7): любая git/HTTP-ошибка → `False` (= «не подтверждено» → alert + HOLD,
fail-closed для `done`). Исключение НИКОГДА не пробрасывается в `advance_stage`.
### D5. `_handle_merge_verify` (оркестрация под-гейта, `src/stage_engine.py`)
Возвращает `True` (вмешался → HOLD, не advance) / `False` (merge подтверждён → штатный advance в `done`):
1. Условность: `merge_verify_applies(repo)` (см. D7) `False` → вернуть `False` (поведение 1:1 как раньше).
2. `sha = validated_revision(...)`; `merge_gate.merge_pr(repo, branch)` (no-op если уже слит).
3. `ok = merge_gate.verify_merged_to_main(repo, branch, sha)`.
4. `ok==True`:
- дописать `merged_to_main: true` во frontmatter `14-deploy-log.md` (машиночитаемая
наблюдаемость; `deploy_status:` НЕ трогаем — контракт парсинга `check_deploy_status`
неизменен), вернуть `False``advance_stage` штатно ведёт `deploy → done`
(терминал-sync/post-deploy-monitor как сегодня; AC-4).
5. `ok==False`:
- **alert** «deploy succeeded but not merged» — Telegram + Plane-коммент;
- `set_issue_blocked(work_item_id)` (Plane не-терминальный; согласовано с ORCH-066
DEGRADED→Blocked и deploy-finalize-exhausted);
- дописать `merged_to_main: false`; **НЕ** `update_task_stage` (задача остаётся на `deploy`),
**НЕ** откат на `development` (not-merged — инфра-дефект, не код; FR-3 → ALERT-only, как
ORCH-021 self-hosting);
- вернуть `True`.
Повтор (re-drive/reaper) переоценит: после ручного устранения merge подтвердится → `done`.
Вся функция обёрнута never-raise: внутренняя ошибка → трактуется как «не подтверждено» (HOLD+alert),
не падение конвейера.
### D6. Идемпотентность (INV-5/AC-9)
- Перед merge — `pr_already_merged` (no-op повтор).
- `verify` зелёный для уже-слитого PR (ветвь `pr_already_merged is True`).
- Повторный прогон ребра `deploy → done` (двойной webhook / reaper / reconciler): merge no-op,
verify зелёный, нет дубль-merge, нет ложного БАГ-8 отката.
### D7. Условность раската (FR-5/AC-10) — `src/config.py`
Новые флаги (паттерн `merge_gate_*`/`image_freshness_*`):
- `merge_verify_enabled: bool = True` — глобальный kill-switch; `False` → строго прежнее
поведение (`_handle_merge_verify` сразу `False`, 1:1 до фикса).
- `merge_verify_repos: str = ""` — CSV; пусто → реально ТОЛЬКО для self-hosting
(`is_self_hosting_repo`); непусто → только перечисленные.
- (опц.) `merge_pr_timeout_s` / `merge_verify_timeout_s` — таймауты Gitea/git.
`merge_verify_applies(repo)` — never-raise, зеркало `self_deploy_applies` / `image_freshness`.
Non-self репо (enduro-trails): под-гейт — **no-op**, merge остаётся за агентом `deployer` (AC-4b).
### D8. Наблюдаемость (опц., FR §2/§3)
Блок `merge_verify` в `GET /queue` (по образцу `reaper`/`post_deploy`): `enabled`,
`merge_verified_total`, `not_merged_alerts_total`, `last_alert_wi`. Каждый alert → `logger.warning`
+ Telegram.
### D9. Диагностический runbook (G4/FR-4)
`docs/operations/PHANTOM_MERGE_RUNBOOK.md` — 4 проверки постмортема с copy-paste командами:
(1) Gitea API список PR + `merged`-флаги; (2) md5 прод-файлов vs `git show origin/main:<file>`;
(3) `git merge-base` ветки vs `main`; (4) таймлайн деплой-логов. + критерий «фантом подтверждён».
## Что НЕ меняется (контракты)
`STAGE_TRANSITIONS`; `check_deploy_status`/`_parse_deploy_status` (читают только `deploy_status:`);
реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG, как
`_handle_merge_gate`); схема БД (restart-safe состояние — существующие sentinel'ы
`.deploy-state-<repo>/<wi>/` + очередь `jobs`); БАГ-8; terminal-sync; merge-gate (ORCH-043);
image-freshness (ORCH-058); `Confirm Deploy` (ORCH-059); post-deploy monitor (ORCH-021);
exit-коды хука (0/1/2); ручной approve прод-деплоя (INV-3). Non-self merge — за агентом `deployer`.
## Последствия
**Плюсы**
- Невозможно состояние «`done` + прод задеплоен, а PR `open`»: либо merge подтверждён → `done`,
либо HOLD + alert (G2/критерий успеха BRD §8).
- Единая врезка в `advance_stage` гейтит ВСЕ пути (finalizer/reconciler/reaper) — нет обходных
дверей к `done`.
- Merge в restart-surviving Phase C структурно не убивается рестартом прода (G3, урок №3).
- Минимальный blast-radius: `STAGE_TRANSITIONS`/`check_deploy_status`/схема БД/реестр QG — нетронуты;
раскат за kill-switch.
**Минусы / ограничения**
- При недоступной Gitea verify консервативно даёт `False` → возможен ложный not-merged alert и
HOLD; снимается повтором после восстановления Gitea (приемлемо: fail-closed для `done` важнее).
- HOLD при not-merged требует ручного вмешательства (ALERT-only) — осознанно (not-merged —
инфра-дефект, авто-откат на `development` запрещён FR-3).
- Появляется реальный исходящий merge-вызов из кода — должно покрываться mock-тестами Gitea
(AC-2) и smoke рестарта (AC-3).
## Альтернативы (отвергнуто)
- **Merge в Phase B (до рестарта).** Гонка с асинхронным рестартом прода → merge может быть убит
на полушаге (постмортем-урок №3). Отвергнуто в пользу restart-surviving Phase C.
- **Новый зарегистрированный QG `check_merged_to_main` на стадии `deploy`.** У стадии один QG
(`check_deploy_status`); второй потребовал бы менять `STAGE_TRANSITIONS`/контракт. Врезка
под-гейта в `advance_stage` (как merge-gate) даёт тот же охват без изменения реестра.
- **Авто-откат на `development` при not-merged.** Запрещено FR-3: not-merged — инфра-дефект,
не код; реакция = alert + ручное вмешательство.

View File

@@ -0,0 +1,47 @@
# 07 — Требования к инфраструктуре (ORCH-071)
## Топология — без изменений
Новой топологии не вводится. Прод `orchestrator` (8500) и staging (8501) — как есть.
Merge выполняется детерминированным кодом в уже существующем restart-surviving Phase C
finalizer (новый контейнер после рестарта), без новых сервисов/портов/контейнеров.
## I-1. Gitea токен с правом merge PR (предусловие)
Merge-актор `merge_gate.merge_pr` вызывает `POST /repos/{owner}/{repo}/pulls/{index}/merge`
через существующий клиент и `settings.gitea_token` / `settings.gitea_url` / `settings.gitea_owner`.
- Требование: тот же `gitea_token`, которым агент `deployer` сегодня мержит PR в `main`,
ДОЛЖЕН иметь право write/merge на репо `orchestrator`. Так как deployer уже мержит этим
токеном — **новых прав, как правило, не требуется** (тот же токен, тот же путь API).
- Действие при раскате: убедиться, что бот-токен — член/коллаборатор репо `orchestrator`
с правом merge (иначе merge_pr вернёт HTTP-ошибку → never-raise → HOLD+alert, не падение).
## I-2. Сетевой доступ контейнера к Gitea
Контейнер прода уже ходит в Gitea API (`pr_already_merged`, webhooks). Дополнительного
сетевого доступа не нужно. При недоступности Gitea verify консервативно даёт «не
подтверждено» → HOLD+alert (fail-closed для `done`).
## I-3. Доступ к `origin/main` из worktree задачи
Верификатор делает `git fetch origin main` + `git merge-base --is-ancestor <sha> origin/main`
в worktree задачи (как `image_freshness`/merge-gate уже делают `git fetch`/`rebase`).
Предусловие — рабочий git-remote `origin` в worktree (есть сегодня). Ошибка fetch →
never-raise → `False` → HOLD+alert.
## I-4. Конфигурация (env, дефолты безопасны)
| Флаг | Дефолт | Назначение |
|------|--------|------------|
| `ORCH_MERGE_VERIFY_ENABLED` | `true` | kill-switch; `false` → строго прежнее поведение (1:1 до фикса) |
| `ORCH_MERGE_VERIFY_REPOS` | `""` | CSV; пусто → только self-hosting (`orchestrator`) |
| `ORCH_MERGE_PR_TIMEOUT_S` (опц.) | напр. 30 | таймаут merge-вызова Gitea |
| `ORCH_MERGE_VERIFY_TIMEOUT_S` (опц.) | напр. 60 | таймаут git fetch/merge-base |
Дефолты не требуют изменения `.env` для штатного раската (область = self-hosting).
Откатить фикс мгновенно можно `ORCH_MERGE_VERIFY_ENABLED=false`.
## I-5. Раскат через staging-гейт (self-hosting safety)
Изменение касается self-deploy пути орка → раскат ОБЯЗАН пройти стадию `deploy-staging`
(8501) перед прод-деплоем (CLAUDE.md §self-hosting). Прод-деплой — только переводом задачи
в статус `Confirm Deploy` (ORCH-059), ручной approve сохранён (INV-3). Никаких рестартов
прода в рамках разработки/ревью.
## I-6. Без миграции БД
Schema-changes запрещены. Restart-safe состояние нового шага — существующие sentinel'ы
`.deploy-state-<repo>/<wi>/` + очередь `jobs` (колонка `jobs.pid`, ORCH-065, уже есть).

View File

@@ -0,0 +1,23 @@
# 10 — Технические риски (ORCH-071)
| ID | Риск | Вероятность / Влияние | Митигация |
|----|------|----------------------|-----------|
| R-1 | **Гонка merge с рестартом прода** (постмортем-урок №3): merge в Phase B убивается рестартом → снова фантом. | Средняя / Критич. | Merge вынесен в **Phase C finalizer** (restart-surviving, новый контейнер ПОСЛЕ рестарта). Merge физически строго после рестарта. Smoke-тест AC-3. |
| R-2 | **Обходной путь к `done`** мимо merge-шага (reconciler F-1 / reaper протолкнут `deploy → done` по зелёному `check_deploy_status`). | Средняя / Критич. | Врезка `_handle_merge_verify` в **`advance_stage`** (единственная функция перехода) → гейтит ВСЕ вызывающие пути единообразно. |
| R-3 | **Ложный not-merged alert при недоступной Gitea** (verify→`False`) → лишний HOLD. | Средняя / Низкое | Осознанный fail-closed для `done`; снимается повтором (re-drive/reconciler) после восстановления Gitea. Alert информативен, не роняет конвейер. |
| R-4 | **Дубль-merge / merge-error** при re-drive (двойной webhook, reaper-requeue). | Средняя / Среднее | `pr_already_merged` ПЕРЕД merge → no-op повтор (INV-5/AC-9). Ложного БАГ-8 отката нет (merge-verify не откатывает). |
| R-5 | **Прямой/force push в `main`** случайно. | Низкая / Критич. | Merge ТОЛЬКО через Gitea PR-merge API (`merge_pr`); код не делает `git push origin main`. INV-4/AC-8, ревью. |
| R-6 | **Verify/merge роняет прод-контейнер** (self-hosting). | Низкая / Критич. | merge_pr/verify — только API + read-only git в worktree; никаких `docker`/restart 8500. INV-2/AC-8. |
| R-7 | **Регрессия non-self деплоя** (enduro-trails). | Низкая / Среднее | Условность `merge_verify_applies` (пусто→self-hosting); non-self — no-op, merge остаётся за `deployer`. AC-4b. |
| R-8 | **HOLD-залипание**: not-merged → Blocked, никто не вмешался → задача вечно не `done`. | Средняя / Среднее | Alert (Telegram+Plane) + Plane `Blocked` (видимый сигнал). Реакция ALERT-only осознанна (not-merged — инфра-дефект, авто-откат запрещён FR-3). Runbook G4 для быстрой локализации. |
| R-9 | **Validated SHA рассинхронизирован** (verify проверяет не тот коммит). | Низкая / Среднее | Единый якорь `validated_revision` (`git rev-parse HEAD` worktree) — тот же, что у image-freshness ORCH-058. |
| R-10 | **Exception из verify валит finalizer/advance_stage**. | Низкая / Высокое | never-raise контракт на всех публичных хелперах + обёртка `_handle_merge_verify`. AC-7. |
| R-11 | **Merge ветки, чей deploy FAILED** (если бы merge был до verify статуса). | — / — | Merge выполняется на ребре `deploy → done`, достигаемом ТОЛЬКО при `deploy_status: SUCCESS`. FAILED → БАГ-8 откат ДО merge-шага (merge не вызывается). |
## Открытые вопросы / follow-up
- **Merge-style** (`merge` / `rebase` / `squash`) в Gitea API — зафиксировать тот же стиль,
что использовал агент `deployer` (по умолчанию `merge`), чтобы не менять историю `main`.
- **Восстановление текущего `main`** (долив 022/059/066/068) — ОТДЕЛЬНАЯ ветка
`integ/restore-main-2026-06-08`, вне scope ORCH-071.
- **Полный авто-деплой** (ORCH-54) — merge-verify совместим, но INV-3 (ручной approve) на
старте сохраняется.

View File

@@ -0,0 +1,51 @@
---
type: review
work_item_id: ORCH-071
verdict: APPROVED
version: 2
---
# Review ORCH-071
## Summary
Фикс «фантомного merge» реализован архитектурно корректно и полно: детерминированный
merge-актор (`merge_gate.merge_pr`) + пост-деплой верификатор (`merge_gate.verify_merged_to_main`)
как под-гейт ребра `deploy → done`, врезанный в единственную точку перехода
`advance_stage` (`_handle_merge_verify`) — гейтит ВСЕ пути к `done` (finalizer Phase C,
reconciler F-1, job-reaper re-drive). Merge выполняется в restart-surviving Phase C (G3),
ТОЛЬКО через Gitea PR-merge API (INV-4, без push/force-push в `main`), идемпотентно
(`pr_already_merged`, INV-5). Условность раската и kill-switch по образцу ORCH-35/43/58,
never-raise контракты соблюдены на всех публичных функциях и в самой врезке.
Все FR-1..FR-5 и AC-1..AC-11 покрыты содержательными тестами (verify true/false/never-raise,
PR-merged short-circuit, kill-switch, non-self no-op, restart-recovery smoke с двухпроходным
re-drive). `pytest tests/ -q` зелёный (853 passed). Код соответствует ADR-001 (D1D9) и
глобальному adr-0013, `STAGE_TRANSITIONS` / `check_deploy_status` / реестр `QG_CHECKS` /
схема БД — не тронуты.
**Прежний блокер (v1) устранён:** `CHANGELOG.md` теперь содержит запись ORCH-071 в
`## [Unreleased] → ### Added` (коммит `ca69ad4`). Документация обновлена полностью.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- (нет; `.openclaw/agents/deployer.md` про self-hosting явно не уточнён, но TRZ §1 помечает
это как «возможное» изменение, а non-self merge-путь по ADR не меняется — не блокер.)
## Документация
- `CHANGELOG.md` — ✅ обновлён: запись ORCH-071 (под-гейт, merge-актор, верификация, kill-switch,
ссылки на ADR/runbook/тесты).
- `docs/architecture/README.md` — ✅ раздел «Merge-в-main + пост-деплой верификация как условие
`done` (ORCH-071)»: врезка, Phase C, merge-актор, верификатор, условность, инварианты.
- `docs/architecture/adr/adr-0013-merge-verify-gate.md` — ✅ global ADR создан.
- `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` — ✅ детальный ADR (D1D9).
- `docs/operations/PHANTOM_MERGE_RUNBOOK.md` — ✅ runbook: 4 проверки постмортема с copy-paste
командами + критерий «фантом подтверждён» + remediation (FR-4/D9).
Задача соответствует ТЗ, ADR и правилам документирования (CLAUDE.md §2/§6). APPROVED.

View File

@@ -0,0 +1,70 @@
---
type: test-report
work_item_id: ORCH-071
result: PASS
---
# Test Report — ORCH-071
Верификация merge-в-main как условие `done` (фантомный merge).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: `feature/ORCH-071-crit-bug-merge-main` (HEAD `d72b1f5`)
- Review verdict: APPROVED (`12-review.md`)
- Дата: 2026-06-08
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | PASS — отдаёт активные задачи (ORCH-071 на стадии testing) |
| `GET /queue` | PASS — counts/resilience/reconcile/reaper/post_deploy в норме, breaker=closed, preflight_ok |
## Результаты по тест-плану (`04-test-plan.yaml`)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | verify True: deployed SHA — предок origin/main | test_tc01_verify_true_when_sha_is_ancestor | PASS |
| TC-02 | verify True: PR.merged==true даже без git | test_tc02_verify_true_when_pr_merged_even_without_git | PASS |
| TC-03 | verify False: фантом (не предок И merged==false) | test_tc03_verify_false_when_phantom | PASS |
| TC-04 | never-raise (AC-7): ошибка git/HTTP → False | test_tc04_verify_never_raises_on_git_error / _http_error | PASS |
| TC-05 | finalizer: SUCCESS но PR open → НЕ done + alert | test_tc05_success_but_not_merged_holds_and_alerts | PASS |
| TC-06 | finalizer: SUCCESS + merge подтверждён → done | test_tc06_success_and_merged_reaches_done | PASS |
| TC-07 | merge-актор зовёт Gitea POST /pulls/{i}/merge | test_tc07_merge_actor_calls_gitea_merge | PASS |
| TC-08 | идемпотентность: already_merged → no-op | test_tc08_idempotent_already_merged / _no_open_pr_is_not_an_error | PASS |
| TC-09 | merge-актор never-raise: ошибка Gitea → (False, reason) | test_tc09_never_raise_on_http_error / _non_2xx_is_false | PASS |
| TC-10 | smoke (AC-3): рестарт в Phase B → re-drive докатывает merge → done | test_tc10_merge_recovers_after_restart | PASS |
| TC-11 | non-self репо: новая логика = no-op | test_tc11_non_self_repo_does_not_apply / _csv_scopes_to_listed_repos | PASS |
| TC-12 | kill-switch off → прежнее поведение | test_tc12_kill_switch_disables_under_gate | PASS |
| TC-13 | self-hosting safety: нет shell-out / force-push в main | test_tc13_no_shell_out_no_force_push | PASS |
| TC-14 | Phase B только при confirm_deploy=True; Approved → no-op | test_tc14_plain_approved_on_deploy_is_noop_no_merge / _confirm_deploy_initiates_phase_b | PASS |
| TC-15 | регресс: check_deploy_status / _parse_deploy_status неизменны | test_tc15_* (7 кейсов) | PASS |
| TC-16 | регресс: STAGE_TRANSITIONS / QG_CHECKS, deploy→done на месте | test_tc16_* (4 кейса) | PASS |
Покрыты все критерии приёмки AC-1..AC-11 (`03-acceptance-criteria.md`).
## Целевой прогон модулей ORCH-071
```
tests/test_merge_verify.py ................ 8 passed
tests/test_merge_actor.py ................. 6 passed
tests/test_deploy_finalizer_merge_gate.py . 4 passed
tests/test_deploy_restart_merge_recovery.py 1 passed
tests/test_qg_checks.py ................... 13 passed
tests/test_stages.py ...................... 4 passed
======================== 36 passed, 1 warning in 0.61s =========================
```
## Полный регресс
```
pytest tests/ -v --tb=short
======================= 853 passed, 1 warning in 22.77s ========================
```
(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не связан с задачей.)
## Итог
**PASS** — все 853 теста зелёные, целевые 36 тестов ORCH-071 (TC-01..TC-16) PASS,
smoke API (health/status/queue) OK. Регрессы существующих контрактов
(`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`) не выявлены.
Задача готова к переходу на `deploy-staging`.

View File

@@ -0,0 +1,23 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T08:44:30Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
run canonically inside the container via `docker exec` (ORCH-048, ADR-001), `--mode stub`.
**Result: 8/10 checks PASS — exit code 0 → SUCCESS.**
All REAL (pipeline) checks green: A1A3 (smoke), B4B6 (access/registry isolation),
C7C8 (E2E issue create + pipeline trigger). The two failing checks are known
sandbox-infra-only checks, tolerated per ORCH-061 (real checks all green):
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
Tolerance flag: `staging_infra_tolerance_enabled=True`. Exit code 0 → `staging_status: SUCCESS`.

View File

@@ -4,3 +4,8 @@ pydantic-settings==2.5.0
httpx==0.27.0
pytest==8.3.3
pytest-asyncio==0.23.8
# ORCH-022: dependency audit (OSV/PyPI advisory) for the security-gate. Needs the
# network at scan time -> an unreachable feed degrades fail-open + warning by
# default (ADR-001 Р-3 / 07-infra I-2). gitleaks (secret-scan) is a pinned Go
# binary baked into the Dockerfile, NOT a pip package.
pip-audit==2.7.3

View File

@@ -682,8 +682,8 @@ class AgentLauncher:
"\u274c Deploy FAILED (smoke/healthcheck). Rolled back. Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
author="deployer",
)
from ..notifications import send_telegram
send_telegram(f"\U0001f6a8 {_wid}: Deploy failed! Rolled back. Needs fix.")
from ..notifications import send_telegram, link_for
send_telegram(f"\U0001f6a8 {link_for(_wid)}: Deploy failed! Rolled back. Needs fix.")
# Notify on startup timeout (exit_code from kill = -9 or 137)
if exit_code != 0 and exit_code not in (None,):
@@ -695,8 +695,8 @@ class AgentLauncher:
conn.close()
if task_row and agent != "deployer": # deployer handled above
_tid, _wid = task_row
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")
from ..notifications import send_telegram, link_for
send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
# Feature 4 + ORCH-016: post the unified per-agent status comment under
# that agent's bot, threading the wall-clock duration we just measured

View File

@@ -219,6 +219,36 @@ class Settings(BaseSettings):
image_freshness_enabled: bool = True
image_freshness_repos: str = ""
# ORCH-022: security-gate (secret-scanning + dependency audit) on the
# deploy-staging -> deploy edge, run FIRST among the edge sub-gates (cheap to
# fail before the expensive rebase/rebuild). Deterministic (no LLM): gitleaks
# (offline secret-scan) + pip-audit (OSV/PyPI dependency audit), verdict in the
# versioned 17-security-report.md frontmatter; FAIL -> rollback to development +
# developer-retry (cap MAX_DEVELOPER_RETRIES). See ADR-001-security-gate.md.
# security_gate_enabled -> SINGLE kill-switch; False -> pipeline 1:1 as
# before ORCH-022 for everyone. Env
# ORCH_SECURITY_GATE_ENABLED.
# security_gate_repos -> CSV of repos where the gate is REAL; empty ->
# only the self-hosting repo (orchestrator).
# Mirrors merge_gate_repos / image_freshness_repos.
# security_dep_block_severity -> CVE severity threshold that BLOCKS (CRITICAL >
# HIGH > MEDIUM > LOW); below it / UNKNOWN -> a
# warning only (anti-loop ADR-001 Р-4).
# security_scan_timeout_s -> per external scanner call timeout (mirrors
# merge_retest_timeout_s).
# security_dep_audit_fail_closed -> strict mode: an unreachable CVE feed -> FAIL
# instead of the default fail-open + warning
# (Р-3). Default False (anti-loop ORCH-061).
# security_secrets_block -> a found secret blocks (always True by default;
# the offline secrets guarantee is unconditional,
# BR-2).
security_gate_enabled: bool = True
security_gate_repos: str = ""
security_dep_block_severity: str = "HIGH"
security_scan_timeout_s: int = 300
security_dep_audit_fail_closed: bool = False
security_secrets_block: bool = True
# ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite.
# The self-hosting deploy-staging stage looped because scripts/staging_check.py
# exited non-zero on ANY failed check, so two infra-only failures (sandbox bot
@@ -265,6 +295,18 @@ class Settings(BaseSettings):
reconcile_notify_unblock: bool = True
reconcile_skip_blocked_enabled: bool = True
# ORCH-068: TTL for the per-project Plane states cache (_STATES_CACHE in
# plane_sync). Historically the cache lived for the whole process lifetime,
# so a status added to Plane after start was never seen without a restart
# ("stale set -> no pipeline action"). With a TTL the entry self-heals by
# re-fetching /states/ after it expires (invalidation reuses the existing
# reload_project_states() primitive — no duplicated reset logic).
# plane_states_ttl_s (env ORCH_PLANE_STATES_TTL_S):
# >0 -> seconds before a cache entry is re-fetched (default 300 = 5 min);
# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat
# escape hatch). get_project_states return shape is unchanged.
plane_states_ttl_s: int = 300
# ORCH-021: post-deploy production monitoring + degradation reaction. After
# the terminal deploy->done transition for an applicable repo, a reserved-agent
# `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod
@@ -332,17 +374,53 @@ class Settings(BaseSettings):
reaper_finalize_grace_s: int = 300
lease_reclaim_enabled: bool = True
# ORCH-071: merge-verify under-gate on the `deploy -> done` edge. For the
# self-hosting repo the `deploy` stage runs the DETERMINISTIC self-deploy path
# (Phase A/B/C), where the LLM `deployer` agent — historically the ONLY actor
# that merged the feature PR into `main` — never runs. Result: a "green" deploy
# could reach `done` while the PR stayed `open` (phantom merge, postmortem
# LESSONS_2026-06-08). This under-gate (врезка in advance_stage, NOT a new
# STAGE_TRANSITIONS edge or registered QG) runs a deterministic merge-actor +
# post-deploy verification before `done`: not-merged -> alert + HOLD (no done),
# merged -> normal advance. Mirrors merge_gate_* / image_freshness_* rollout.
# merge_verify_enabled -> global kill-switch; False -> strictly the prior
# behaviour (no merge/verify), env ORCH_MERGE_VERIFY_ENABLED.
# merge_verify_repos -> CSV of repos where the under-gate is REAL; empty ->
# only the self-hosting repo (orchestrator). Mirrors
# merge_gate_repos / self_deploy_repos.
# merge_pr_timeout_s -> per Gitea merge/list HTTP call timeout.
# merge_verify_timeout_s-> git fetch/merge-base timeout for the ancestor check.
merge_verify_enabled: bool = True
merge_verify_repos: str = ""
merge_pr_timeout_s: int = 60
merge_verify_timeout_s: int = 60
# 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"
# bump (ДЕФОЛТ с ORCH-067) -> при обновлении старое сообщение удаляется и
# карточка отправляется заново вниз чата (deleteMessage + sendMessage
# + repoint message_id), тихо (disable_notification).
# edit -> карточка редактируется на месте (editMessageText); доступен через
# ORCH_TRACKER_MODE=edit.
# Одна карточка на задачу в обоих режимах. Неизвестное/пустое значение
# трактуется как edit (см. notifications).
tracker_mode: str = "bump"
# ORCH-067 (ADR Р-2/Р-3/Р-4): best-effort live-overlay для статус-строки
# карточки. Дорисовывает ветки Plane-статуса, неотличимые offline по
# tasks.stage (Needs Input / Blocked / Rejected / Cancelled / Deploying /
# Monitoring after Deploy) — читая ЖИВОЙ Plane-статус с коротким таймаутом и
# TTL-кэшем. Offline-ядро (stage -> статус, In Review из brd-clock) работает
# всегда без сети; overlay лишь дополняет его и НИКОГДА не блокирует конвейер.
# tracker_live_status -> kill-switch (False -> только offline-ядро).
# tracker_live_status_ttl_s -> TTL per-issue кэша live-uuid (защита hot-path).
# tracker_live_status_timeout_s -> таймаут одного live-GET в пути рендера.
tracker_live_status: bool = True
tracker_live_status_ttl_s: int = 60
tracker_live_status_timeout_s: int = 3
class Config:
env_prefix = "ORCH_"

View File

@@ -147,6 +147,7 @@ async def queue():
from .reconciler import reconciler
from .job_reaper import reaper
from . import post_deploy
from . import merge_gate
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
@@ -155,5 +156,6 @@ async def queue():
"reconcile": reconciler.status(),
"reaper": reaper.status(),
"post_deploy": post_deploy.status(),
"merge_verify": merge_gate.merge_verify_status(),
"recent": recent_jobs(10),
}

View File

@@ -485,3 +485,193 @@ def pr_already_merged(repo: str, branch: str) -> bool:
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("pr_already_merged check failed for %s/%s: %s", repo, branch, e)
return False
# ---------------------------------------------------------------------------
# ORCH-071: deterministic merge-actor + post-deploy merge verification.
#
# For the self-hosting repo the `deploy` stage runs the deterministic self-deploy
# path (Phase A/B/C) and the LLM `deployer` agent — historically the ONLY actor
# that merged the feature PR into `main` — never runs. These two helpers close the
# "phantom merge" gap (LESSONS_2026-06-08): a deterministic actor merges the PR via
# the Gitea PR-merge API (NEVER a push/force-push to main, INV-4) and a verifier
# confirms `main` actually received the commit before the pipeline reaches `done`.
# Both wire into the `deploy -> done` under-gate (stage_engine._handle_merge_verify).
# ---------------------------------------------------------------------------
# Lightweight in-process observability counters (D8). Reset only on process start;
# surfaced read-only via `merge_verify_status()` in GET /queue. Never the source of
# truth for any decision — purely informational.
_MERGE_VERIFY_COUNTERS: dict = {
"merge_verified_total": 0,
"not_merged_alerts_total": 0,
"last_alert_wi": None,
}
def note_merge_verified() -> None:
"""Bump the 'merge verified -> done' counter (observability only). Never raises."""
try:
_MERGE_VERIFY_COUNTERS["merge_verified_total"] += 1
except Exception: # noqa: BLE001 - observability must never break a decision
pass
def note_not_merged_alert(work_item_id: str | None) -> None:
"""Bump the 'deploy succeeded but not merged' counter. Never raises."""
try:
_MERGE_VERIFY_COUNTERS["not_merged_alerts_total"] += 1
_MERGE_VERIFY_COUNTERS["last_alert_wi"] = work_item_id
except Exception: # noqa: BLE001 - observability must never break a decision
pass
def merge_verify_status() -> dict:
"""Snapshot of the merge-verify under-gate for GET /queue. Never raises."""
try:
return {
"enabled": bool(settings.merge_verify_enabled),
"repos": settings.merge_verify_repos or "",
"merge_verified_total": _MERGE_VERIFY_COUNTERS["merge_verified_total"],
"not_merged_alerts_total": _MERGE_VERIFY_COUNTERS["not_merged_alerts_total"],
"last_alert_wi": _MERGE_VERIFY_COUNTERS["last_alert_wi"],
}
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("merge_verify_status error: %s", e)
return {"enabled": False}
def merge_verify_applies(repo: str) -> bool:
"""Whether the ORCH-071 merge-verify under-gate is REAL for this repo.
Mirrors ``self_deploy_applies`` / ``image_freshness_applies`` (FR-5 / AC-10):
* ``merge_verify_enabled=False`` -> always False (global kill-switch -> the
pipeline behaves exactly as before ORCH-071 for everyone).
* ``merge_verify_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``); other
repos keep the LLM-``deployer`` merge path unchanged (AC-4b).
Never raises (any error -> False = no-op, the safe default).
"""
try:
if not settings.merge_verify_enabled:
return False
raw = (settings.merge_verify_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 a leaf-ish module (qg.checks imports merge_gate lazily).
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("merge_verify_applies error for %s: %s", repo, e)
return False
def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
"""Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API.
The self-hosting deterministic merge-actor (FR-1 / D3). NEVER pushes or
force-pushes ``main`` (INV-4/AC-8) — the ONLY mutation is the Gitea
``POST /pulls/{index}/merge`` call, exactly what the LLM ``deployer`` used to do
on non-self repos.
Algorithm:
1. ``pr_already_merged`` -> True -> no-op ``(True, "already-merged")`` (INV-5/AC-9).
2. ``GET /repos/{owner}/{repo}/pulls?state=open`` -> the open PR whose head ref
== ``branch`` -> its index. No open PR -> ``(False, "no open PR")``.
3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) ->
200/201 -> ``(True, "merged PR #<n>")``; otherwise ``(False, "<reason>")``.
Never-raise (INV-1/AC-9 / TC-09): any HTTP/parse error -> ``(False, reason)``.
"""
try:
if pr_already_merged(repo, branch):
logger.info("merge_pr: %s/%s already merged -> no-op", repo, branch)
return True, "already-merged"
import httpx
owner = settings.gitea_owner
headers = {"Authorization": f"token {settings.gitea_token}"}
base = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}"
timeout = settings.merge_pr_timeout_s
resp = httpx.get(
f"{base}/pulls", params={"state": "open"}, headers=headers, timeout=timeout
)
if resp.status_code != 200:
return False, f"list PRs failed: HTTP {resp.status_code}"
index = None
for pr in resp.json() or []:
if pr.get("head", {}).get("ref") == branch:
index = pr.get("number")
break
if index is None:
return False, "no open PR"
m = httpx.post(
f"{base}/pulls/{index}/merge",
json={"Do": "merge"},
headers=headers,
timeout=timeout,
)
if m.status_code in (200, 201):
logger.info("merge_pr: merged PR #%s for %s/%s", index, repo, branch)
return True, f"merged PR #{index}"
detail = (m.text or "").strip()[:200]
logger.warning(
"merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s",
repo, branch, index, m.status_code, detail,
)
return False, f"merge failed: HTTP {m.status_code}"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("merge_pr unexpected error for %s/%s: %s", repo, branch, e)
return False, f"merge error: {e}"
def verify_merged_to_main(repo: str, branch: str, sha: str) -> bool:
"""Return True iff the deployed commit is confirmed merged into ``origin/main``.
Post-deploy verification (FR-2 / D4): the merge is confirmed when EITHER
* ``pr_already_merged(repo, branch)`` is True (Gitea ``PR.merged == true``), OR
* ``git merge-base --is-ancestor <sha> origin/main`` succeeds in the per-branch
worktree (after ``git fetch origin main``), i.e. the validated SHA is an
ancestor of the current ``origin/main``.
``sha`` is the validated commit (``image_freshness.validated_revision`` =
worktree ``git rev-parse HEAD``). An empty ``sha`` makes the git branch
inconclusive (only the PR-merged branch can then confirm).
Never-raise (INV-1/AC-7 / TC-04): any git/HTTP error -> ``False`` (= "not
confirmed" -> fail-closed for ``done``: alert + HOLD). The exception is NEVER
propagated into ``advance_stage``.
"""
try:
if pr_already_merged(repo, branch):
return True
if not sha:
logger.warning(
"verify_merged_to_main: empty SHA for %s/%s and PR not known-merged",
repo, branch,
)
return False
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning(
"verify_merged_to_main: worktree error for %s/%s: %s", repo, branch, e
)
return False
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=settings.merge_verify_timeout_s,
)
r = subprocess.run(
["git", "-C", wt, "merge-base", "--is-ancestor", sha, "origin/main"],
capture_output=True, timeout=settings.merge_verify_timeout_s,
)
return r.returncode == 0
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning(
"verify_merged_to_main unexpected error for %s/%s: %s", repo, branch, e
)
return False

View File

@@ -307,7 +307,7 @@ def render_task_tracker(task_id: int) -> str:
conn = get_db()
task = conn.execute(
"SELECT id, work_item_id, title, stage, created_at, updated_at, "
"brd_review_started_at, brd_review_ended_at "
"brd_review_started_at, brd_review_ended_at, repo, plane_issue_id "
"FROM tasks WHERE id=?",
(task_id,),
).fetchone()
@@ -358,13 +358,27 @@ def render_task_tracker(task_id: int) -> str:
agent_seconds += d
esc_title = html.escape(title)
# ORCH-067 (req 3): the issue number in the header is now a clickable link to
# the Plane issue (degrades to the escaped number when no web URL \u2014 fail-safe).
task_repo = _row_get(task, "repo")
task_issue_id = _row_get(task, "plane_issue_id")
num_html = plane_issue_link(work_item_id, plane_issue_id=task_issue_id, repo=task_repo)
header = (
f"\U0001f389 {html.escape(work_item_id)} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e"
f"\U0001f389 {num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e"
if done
else f"\U0001f6e0\ufe0f {html.escape(work_item_id)} \u00b7 {esc_title}"
else f"\U0001f6e0\ufe0f {num_html} \u00b7 {esc_title}"
)
bar = "\u2501" * 22
lines = [header, bar]
# ORCH-067 (req 2): a Plane-status line (model ORCH-066) under the header.
# Built fail-safe: any error degrades to a stage default, never breaks render.
try:
status_label = _card_status_label(
task, repo=task_repo, plane_issue_id=task_issue_id
)
except Exception:
status_label = _DEFAULT_STATUS_LABEL
status_line = f"\U0001f4cd {status_label}"
lines = [header, status_line, bar]
def _stage_line(label, run):
usage = {
@@ -704,38 +718,276 @@ def _build_brd_link(repo, branch, work_item_id) -> str | None:
)
def _plane_issue_url(repo, plane_issue_id, project_id=None) -> str | None:
"""ORCH-067 (Р-5): build the Plane issue browser URL, or None if unbuildable.
Single source of the URL + guards, shared by ``plane_issue_link`` (link text =
issue number) and ``_build_plane_issue_link`` (link text = '✅ Задача в Plane'),
so the project resolution and loopback-guard live in ONE place (ORCH-017 Р-2).
Full path: ``{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/``.
web_base = plane_web_url or plane_api_url; a loopback base counts as "no web
URL" -> None. ``project_id`` is taken explicitly when given, else resolved from
``repo``. Never raises.
"""
try:
s = _get_settings()
web_base = (
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
).rstrip("/")
workspace = getattr(s, "plane_workspace_slug", "")
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
return None
if not project_id:
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
project_id = getattr(project, "plane_project_id", "") if project else ""
if not project_id:
return None
return (
f"{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/"
)
except Exception:
return None
def _build_plane_issue_link(repo, plane_issue_id) -> str | None:
"""ORCH-017: '<a>' to the Plane issue browser page, or None if unusable.
Full path per ADR-001 Р-2:
``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``.
web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated
as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6).
Link text = '✅ Задача в Plane'. URL built by the shared ``_plane_issue_url``
(loopback / workspace / project guards, ADR-001 Р-2 / ORCH-067 Р-5).
"""
s = _get_settings()
web_base = (
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
).rstrip("/")
workspace = getattr(s, "plane_workspace_slug", "")
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
url = _plane_issue_url(repo, plane_issue_id)
if not url:
return None
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
if not project or not getattr(project, "plane_project_id", ""):
return None
url = (
f"{web_base}/{workspace}/projects/{project.plane_project_id}"
f"/issues/{plane_issue_id}/"
)
return (
f'<a href="{html.escape(url, quote=True)}">'
f"✅ Задача в Plane</a>"
)
def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str:
"""ORCH-067 (Р-5): clickable issue number for cards / alerts.
Returns ``<a href=...>ORCH-NNN</a>`` when a Plane web URL can be built, else
``html.escape(work_item_id)`` (number without a link). Never raises.
Link text is always ``html.escape(work_item_id)``; the href is built by the
shared ``_plane_issue_url`` (same loopback / workspace / project guards as the
'✅ Задача в Plane' link). On any missing piece -> the escaped number.
"""
label = html.escape(str(work_item_id)) if work_item_id is not None else ""
try:
url = _plane_issue_url(repo, plane_issue_id, project_id)
if not url:
return label
return f'<a href="{html.escape(url, quote=True)}">{label}</a>'
except Exception:
return label
def link_for(work_item_id, task_id=None) -> str:
"""ORCH-067 (Р-6): clickable issue number for alert points that hold only a
``work_item_id`` (or ``task_id``).
Resolves ``(repo, plane_issue_id)`` from the DB (by ``task_id`` when given,
else the latest task row for ``work_item_id``) and delegates to
``plane_issue_link``. On any missing data -> ``html.escape(work_item_id)``.
Never raises.
"""
if not work_item_id:
return html.escape(str(work_item_id)) if work_item_id is not None else ""
repo = None
plane_issue_id = None
try:
from .db import get_db
conn = get_db()
if task_id is not None:
row = conn.execute(
"SELECT repo, plane_issue_id FROM tasks WHERE id=?", (task_id,)
).fetchone()
else:
row = conn.execute(
"SELECT repo, plane_issue_id FROM tasks WHERE work_item_id=? "
"ORDER BY id DESC LIMIT 1",
(work_item_id,),
).fetchone()
conn.close()
if row:
repo = row["repo"]
plane_issue_id = row["plane_issue_id"]
except Exception as e:
logger.debug(f"link_for({work_item_id}) DB lookup failed: {e}")
return plane_issue_link(work_item_id, plane_issue_id=plane_issue_id, repo=repo)
# --------------------------------------------------------------------------- #
# ORCH-067: Plane status label for the live card (layer B indication, ADR Р-1)
# --------------------------------------------------------------------------- #
# Offline stage -> Plane status label. Names are the final ORCH-066 status names
# (_PLANE_NAME_TO_KEY). Pure / deterministic — derived entirely from tasks.stage
# (+ the brd-clock for In Review), NEVER from the network.
_STAGE_STATUS_LABEL = {
"created": "To Analyse",
"analysis": "Analysis",
"architecture": "Architecture",
"development": "Development",
"review": "Code-Review",
"testing": "Testing",
"deploy": "⏸️ Awaiting Deploy — ожидание Confirm Deploy",
"done": "Done",
}
_DEFAULT_STATUS_LABEL = "To Analyse"
_IN_REVIEW_LABEL = (
"⏸️ In Review — ожидание "
"согласования BRD"
)
# Live-overlay branch labels (keys not derivable offline from tasks.stage).
_LIVE_BRANCH_LABELS = {
"needs_input": "❓ Needs Input — нужны уточнения",
"blocked": "Blocked",
"rejected": "Rejected",
"cancelled": "Cancelled",
"deploying": "Deploying",
"monitoring": "Monitoring after Deploy",
}
# ORCH-066 (Р-1 anti-false-positive): deploying/monitoring alias their BASE key's
# UUID on a project without dedicated statuses (enduro). Override is applied ONLY
# when the project really defined a SEPARATE UUID for the branch key.
_LIVE_BRANCH_BASE = {
"deploying": "in_progress",
"monitoring": "done",
}
def _row_get(row, key, default=None):
"""Safe sqlite3.Row / dict / object getter. Never raises."""
try:
return row[key]
except Exception:
try:
return getattr(row, key, default)
except Exception:
return default
def plane_status_label(task_row) -> str:
"""ORCH-067 (Р-1, layer 1): current Plane status label for the card header.
Pure / deterministic from the task row, NEVER hits the network, NEVER raises.
On unknown / broken input -> a safe stage default. ``⏸️ In Review`` and
``⏸️ Awaiting Deploy`` are produced here (offline), so both work without a
network connection (AC-7, AC-8). Branch statuses that are indistinguishable
offline (Needs Input / Blocked / …) are drawn by ``_live_plane_branch_override``.
"""
try:
stage = _row_get(task_row, "stage") or "created"
except Exception:
return _DEFAULT_STATUS_LABEL
try:
if stage == "analysis":
started = _row_get(task_row, "brd_review_started_at")
ended = _row_get(task_row, "brd_review_ended_at")
if started and not ended:
return _IN_REVIEW_LABEL
return _STAGE_STATUS_LABEL.get(stage, _DEFAULT_STATUS_LABEL)
except Exception:
return _DEFAULT_STATUS_LABEL
# ORCH-067 (Р-3): per-issue TTL cache of the live state uuid -> {issue_id: (ts, uuid)}.
_LIVE_STATE_CACHE: dict[str, tuple] = {}
def _live_state_uuid_cached(plane_issue_id, project_id):
"""ORCH-067 (Р-3/Р-4): TTL-cached single live-state read for the render path.
At most one ``fetch_issue_state`` per issue per ``tracker_live_status_ttl_s``
with a SHORT timeout. Never raises -> None on any failure.
"""
try:
import time
s = _get_settings()
ttl = getattr(s, "tracker_live_status_ttl_s", 60)
now = time.monotonic()
hit = _LIVE_STATE_CACHE.get(plane_issue_id)
if hit is not None and (now - hit[0]) <= ttl:
return hit[1]
from .plane_sync import fetch_issue_state
timeout = getattr(s, "tracker_live_status_timeout_s", 3)
uuid = fetch_issue_state(plane_issue_id, project_id, timeout=timeout)
_LIVE_STATE_CACHE[plane_issue_id] = (now, uuid)
return uuid
except Exception as e:
logger.debug(f"_live_state_uuid_cached({plane_issue_id}) failed: {e}")
return None
def _live_plane_branch_override(repo, plane_issue_id, base_label) -> str:
"""ORCH-067 (Р-1 layer 2 / Р-2): best-effort live-status overlay.
Draws the branch statuses that are indistinguishable from ``tasks.stage``
offline (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring
after Deploy) by reading the LIVE Plane status (short timeout, TTL cache). Any
failure / disabled kill-switch / missing data -> ``base_label`` (offline). The
pipeline is NEVER blocked. Never raises.
"""
try:
s = _get_settings()
if not getattr(s, "tracker_live_status", True):
return base_label
if not plane_issue_id:
return base_label
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
project_id = getattr(project, "plane_project_id", "") if project else ""
if not project_id:
return base_label
live_uuid = _live_state_uuid_cached(plane_issue_id, project_id)
if not live_uuid:
return base_label
from .plane_sync import get_project_states
states = get_project_states(project_id)
for key, label in _LIVE_BRANCH_LABELS.items():
uuid = states.get(key)
if not uuid or uuid != live_uuid:
continue
base_key = _LIVE_BRANCH_BASE.get(key)
if base_key and states.get(base_key) == uuid:
# deploying/monitoring just alias their base key on this project
# (enduro / no dedicated status) -> not a real branch, don't override.
continue
return label
return base_label
except Exception as e:
logger.debug(f"_live_plane_branch_override failed: {e}")
return base_label
def _card_status_label(task_row, repo=None, plane_issue_id=None) -> str:
"""ORCH-067: full status label for the card = offline core + live overlay.
Precedence (Р-1): if the offline core resolved ``⏸️ In Review`` (brd-clock,
authoritative) the overlay is NOT consulted; otherwise the overlay may draw a
branch status. Never raises (AC-9).
"""
try:
base = plane_status_label(task_row)
if base == _IN_REVIEW_LABEL:
return base
return _live_plane_branch_override(repo, plane_issue_id, base)
except Exception:
return _DEFAULT_STATUS_LABEL
def notify_approve_requested(task_id: int):
"""ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved.
@@ -749,7 +1001,7 @@ def notify_approve_requested(task_id: int):
except Exception as e:
logger.warning(f"notify_approve_requested: brd clock start failed: {e}")
msg = (
f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\U0001f4cb {link_for(work_item_id, task_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved "
f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f."
)
@@ -783,8 +1035,14 @@ def notify_done(task_id: int):
def notify_error(task_id: int, error: str):
"""ALERT (separate, notifying): task error."""
"""ALERT (separate, notifying): task error.
ORCH-067 (req 4): the issue number is a clickable Plane link (fail-safe ->
raw number) and the error text is html-escaped so it cannot break the <a>
markup under parse_mode=HTML (AC-14).
"""
work_item_id = _get_work_item_id(task_id) if task_id else "system"
msg = f"\U0001f534 {work_item_id}: ERROR \u2014 {error}"
num = link_for(work_item_id, task_id) if task_id else html.escape(work_item_id)
msg = f"\U0001f534 {num}: ERROR \u2014 {html.escape(str(error))}"
logger.error(msg)
send_telegram(msg) # separate, notifying

View File

@@ -1,6 +1,7 @@
"""Plane API sync — update issue state and add comments."""
import logging
import time
import httpx
from .config import settings
@@ -107,6 +108,19 @@ _DEFAULT_STATES = {
# Feature 2 (verdict statuses) — Approved / Rejected.
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
# ORCH-066 (meaningful Plane status model, layer B): six new logical keys.
# Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key
# (see _STATE_ALIAS_FALLBACK) so a project without these statuses created
# (enduro / Plane down / partial config) degrades to the current behaviour
# instead of producing an invalid PATCH state. The project-relative
# alias-fallback in get_project_states() overrides these with the *project's
# own* base UUID on the success path; these defaults are the last resort.
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
"analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
"code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review
"awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review
"deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
"monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done
}
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
@@ -134,20 +148,67 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
# this board status (enduro / API fallback) fail-closed — no UUID, no
# confirm-deploy branch, no KeyError (accessed via .get).
"Confirm Deploy": "confirm_deploy",
# ORCH-066: meaningful per-stage / human-input statuses (layer B).
"To Analyse": "to_analyse",
"Analysis": "analysis",
"Code-Review": "code_review",
"Awaiting Deploy": "awaiting_deploy",
"Deploying": "deploying",
"Monitoring after Deploy": "monitoring",
}
# Per-project state cache: {project_id: {logical_key: state_uuid}}
_STATES_CACHE: dict[str, dict[str, str]] = {}
# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys.
# After resolving states by name from the Plane API, any NEW key the project did
# not define degrades to the UUID of its BASE key **from the same project** — so
# the indication falls back to the current status and the PATCH stays valid even
# for a partially-configured project. Enduro (none of the new statuses created)
# collapses every new key onto its base, i.e. strictly the pre-ORCH-066
# behaviour. Strengthened ORCH-059 AC-7 pattern.
_STATE_ALIAS_FALLBACK: dict[str, str] = {
"to_analyse": "in_progress",
"analysis": "in_progress",
"code_review": "review",
"awaiting_deploy": "in_review",
"deploying": "in_progress",
"monitoring": "done",
}
# Per-project state cache (ORCH-10 + ORCH-068).
#
# Each entry is a RECORD, not a bare mapping:
# {"states": {logical_key: state_uuid}, # the ORCH-10 mapping (unchanged shape)
# "groups": {state_uuid: group}, # ORCH-068 D1: {uuid -> Plane state.group}
# "ts": monotonic timestamp} # ORCH-068 TR-4: for TTL self-heal
# get_project_states() still RETURNS the bare {logical_key: state_uuid} mapping
# (backward compatible — AC-13); the richer record is internal.
_STATES_CACHE: dict[str, dict] = {}
def _cache_record_fresh(record: dict) -> bool:
"""ORCH-068 (TR-4): is a cache record still within its TTL?
``plane_states_ttl_s <= 0`` disables the TTL -> a record never expires
(strictly the previous lifetime-cache behaviour, back-compat escape hatch).
"""
ttl = settings.plane_states_ttl_s
if ttl <= 0:
return True
ts = record.get("ts", 0.0)
return (time.monotonic() - ts) <= ttl
def get_project_states(project_id: str) -> dict[str, str]:
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
Source of truth: Plane API GET /projects/<project_id>/states/.
Results are cached per project_id for the lifetime of the process.
Results are cached per project_id. ORCH-068 (TR-4): a cached entry is
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s) so a
status added to Plane after start self-heals without a process restart;
``plane_states_ttl_s = 0`` keeps the previous lifetime cache.
Falls back to _DEFAULT_STATES (enduro-trails values) if:
* project_id is empty/None,
* the API call fails (network error, non-2xx),
* the API call fails (network error, non-2xx) AND nothing is cached,
* the response contains no recognisable states.
The enduro-trails project therefore returns the same UUIDs as before
@@ -157,8 +218,9 @@ def get_project_states(project_id: str) -> dict[str, str]:
if not project_id:
return _DEFAULT_STATES
if project_id in _STATES_CACHE:
return _STATES_CACHE[project_id]
cached = _STATES_CACHE.get(project_id)
if cached is not None and _cache_record_fresh(cached):
return cached["states"]
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
try:
@@ -171,28 +233,60 @@ def get_project_states(project_id: str) -> dict[str, str]:
raise ValueError(f"unexpected states response shape: {type(items)}")
resolved: dict[str, str] = {}
groups: dict[str, str] = {}
for item in items:
name = item.get("name", "")
uid = item.get("id", "")
key = _PLANE_NAME_TO_KEY.get(name)
if key and uid:
resolved[key] = uid
# ORCH-068 D1: capture {uuid -> group} for terminal-state detection
# (a single API fetch — no extra network cost). The group is the
# authoritative, project-independent discriminator of terminal
# (completed/cancelled) vs review/work statuses, robust to UUID
# aliasing after status renames (ORCH-066).
grp = item.get("group", "")
if uid and grp:
groups[uid] = grp
if not resolved:
raise ValueError("no recognisable states in API response")
# ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the
# project did not define, reuse the UUID of its BASE key FROM THIS SAME
# PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH
# state on a partially-configured orchestrator project). Runs BEFORE the
# _DEFAULT_STATES.setdefault below so a project's own base UUID wins over
# the static enduro default.
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
if new_key not in resolved and resolved.get(base_key):
resolved[new_key] = resolved[base_key]
# Fill any missing keys from _DEFAULT_STATES so callers always get a
# complete mapping (defensive against partial Plane configs).
for k, v in _DEFAULT_STATES.items():
resolved.setdefault(k, v)
_STATES_CACHE[project_id] = resolved
_STATES_CACHE[project_id] = {
"states": resolved,
"groups": groups,
"ts": time.monotonic(),
}
logger.debug(
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
f"get_project_states: cached {len(resolved)} states / "
f"{len(groups)} groups for project {project_id[:8]}..."
)
return resolved
except Exception as e:
# On a transient API failure keep serving the stale (but project-correct)
# set if we have one — far safer than reverting to enduro defaults.
if cached is not None:
logger.warning(
f"get_project_states: API refresh failed for project "
f"{project_id[:8]}..., serving stale cached set. Error: {e}"
)
return cached["states"]
logger.warning(
f"get_project_states: API failed for project {project_id[:8]}..., "
f"falling back to _DEFAULT_STATES. Error: {e}"
@@ -200,6 +294,23 @@ def get_project_states(project_id: str) -> dict[str, str]:
return _DEFAULT_STATES
def get_project_state_groups(project_id: str) -> dict[str, str]:
"""ORCH-068 (D1): return {state_uuid -> group} for a Plane project.
Reads the SAME cache record populated by ``get_project_states`` (no extra
network call). Call ``get_project_states(project_id)`` first to ensure the
record is fresh/populated. Returns ``{}`` when nothing is cached (e.g. the
API was unreachable and the caller fell back to ``_DEFAULT_STATES``); the
reconciler then falls back to logical terminal keys.
"""
record = _STATES_CACHE.get(project_id)
if isinstance(record, dict):
groups = record.get("groups")
if isinstance(groups, dict):
return groups
return {}
def reload_project_states(project_id: str = None) -> None:
"""ORCH-10: clear the per-project states cache.
@@ -216,14 +327,16 @@ def reload_project_states(project_id: str = None) -> None:
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
# when the pipeline ENTERS that stage. analysis stays driven by the existing
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
# in_progress until done. Needs Input / In Review / Blocked remain higher
# priority and are set explicitly elsewhere — do NOT override them from here.
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress
# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked
# remain higher priority and are set explicitly elsewhere — do NOT override them
# from here.
STAGE_VISIBILITY_STATE = {
"analysis": "analysis", # ORCH-066: analysis stage -> Analysis status
"architecture": "architecture",
"development": "development",
"review": "review",
"review": "code_review", # ORCH-066: review stage -> Code-Review status
"testing": "testing",
}
@@ -231,22 +344,27 @@ STAGE_VISIBILITY_STATE = {
# update_issue_state now calls stage_to_state() instead of looking up here.
STAGE_TO_STATE = {
"created": _DEFAULT_STATES["todo"],
"analysis": _DEFAULT_STATES["in_progress"],
# ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias
# the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers /
# tests that compare against concrete UUIDs see byte-identical values.
"analysis": _DEFAULT_STATES["analysis"],
"architecture": _DEFAULT_STATES["architecture"],
"development": _DEFAULT_STATES["development"],
"review": _DEFAULT_STATES["review"],
"review": _DEFAULT_STATES["code_review"],
"testing": _DEFAULT_STATES["testing"],
"deploy": _DEFAULT_STATES["in_progress"],
"done": _DEFAULT_STATES["done"],
}
# Map orchestrator stage -> logical state key (project-independent).
# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review).
# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state).
_STAGE_TO_STATE_KEY = {
"created": "todo",
"analysis": "in_progress",
"analysis": "analysis",
"architecture": "architecture",
"development": "development",
"review": "review",
"review": "code_review",
"testing": "testing",
"deploy": "in_progress",
"done": "done",
@@ -284,7 +402,7 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
return None
def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> str | None:
"""ORCH-060 (F-1 Guard 2): GET the Plane issue and return its current state uuid.
Used by the reconciler to honour an explicit human gate: an issue a person
@@ -295,12 +413,16 @@ def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
Plane returns ``state`` as a bare uuid string; older shapes may nest it as a
``{"id": ...}`` dict — both are handled.
ORCH-067 (Р-4): ``timeout`` is optional (default 10s — unchanged for the
reconciler) so the tracker live-overlay can read with a SHORT timeout
(settings.tracker_live_status_timeout_s) on the synchronous render path.
Returns None on network error, non-2xx, or a missing field — never raises, so
the caller can apply its conservative fallback (treat as "possibly blocked").
"""
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout)
resp.raise_for_status()
state = resp.json().get("state")
if isinstance(state, dict):
@@ -581,6 +703,58 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None):
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_analysis(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Analysis' — analyst is working (start / resume).
Degrades to the project's In Progress UUID when the 'Analysis' status is not
created (alias-fallback). never-raise (via _set_issue_state_direct).
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["analysis"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_code_review(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Code-Review' — review stage indication.
Degrades to the project's Review UUID when 'Code-Review' is not created.
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["code_review"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending.
Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created.
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["awaiting_deploy"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_deploying(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight.
Degrades to the project's In Progress UUID when 'Deploying' is not created.
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["deploying"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_monitoring(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open.
Degrades to the project's Done UUID when 'Monitoring after Deploy' is not
created (so the board shows Done, exactly as before ORCH-066).
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["monitoring"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
"""Feature 3: move the issue to the board status for a pipeline stage.

View File

@@ -716,6 +716,23 @@ def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tup
return check_staging_image_fresh(repo, work_item_id, branch)
def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-022 security sub-gate (secret-scan + dependency audit) on the
deploy-staging -> deploy edge, run FIRST (before merge-gate / image-freshness).
Thin registry wrapper that delegates to ``security_gate.check_security_gate``
(gitleaks offline + pip-audit, write/read-back ``17-security-report.md``). The
real logic lives in ``src/security_gate.py`` (leaf module, never-raise,
fail-closed on secrets, fail-open degrade for the dep-audit feed); importing it
lazily here avoids an import cycle (security_gate imports is_self_hosting_repo
from this module). For non-self repos with an empty scope it returns
``(True, "security-gate N/A for <repo>")`` so the deploy edge is unchanged for
them (AC-13/TC-13).
"""
from ..security_gate import check_security_gate as _impl
return _impl(repo, work_item_id, branch)
# Registry for dynamic lookup by name
QG_CHECKS = {
"check_analysis_approved": check_analysis_approved,
@@ -730,4 +747,5 @@ QG_CHECKS = {
"check_staging_status": check_staging_status,
"check_branch_mergeable": check_branch_mergeable,
"check_staging_image_fresh": _check_staging_image_fresh,
"check_security_gate": check_security_gate,
}

View File

@@ -60,9 +60,14 @@ from .stage_engine import (
MAX_DEVELOPER_RETRIES,
)
from .stages import get_qg_for_stage
from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state
from .plane_sync import (
fetch_issue_state,
get_project_states,
get_project_state_groups,
list_issues_by_state,
)
from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram
from .notifications import send_telegram, link_for
from . import projects
logger = logging.getLogger("orchestrator.reconciler")
@@ -139,6 +144,13 @@ class Reconciler:
self.last_run_ts: float | None = None
self.unblocked_total: int = 0
self.last_unblocked: str | None = None
# ORCH-068 observability: terminal-state skips and dedup suppressions.
self.skipped_terminal_total: int = 0
self.deduped_total: int = 0
# ORCH-068 (TR-3): in-memory dedup guard {issue_id -> last unblocked
# state uuid}. Best-effort (resets on restart, like unblocked_total);
# suppresses a repeat unblock notification for the same issue+state.
self._unblock_dedup: dict[str, str] = {}
# -- F-1: gate-side ----------------------------------------------------
def reconcile_gate_once(self) -> None:
@@ -193,12 +205,22 @@ class Reconciler:
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
def _is_blocked_or_needs_input(self, task: dict) -> bool:
"""ORCH-060 Guard 2: is this issue in an explicit human Plane gate?
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
an active orchestrator wait that F-1 must not "revive"?
Variant A (no schema migration): resolve the task's Plane project, fetch
the issue's current state uuid and compare against the project's
``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so
the live Plane state is the source of truth.
the issue's current state uuid and compare against a skip-set. ``tasks``
has no status column, so the live Plane state is the source of truth.
Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the
ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``,
BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto
``in_review`` / ``in_progress`` / ``done`` on a project that did not create
them. Adding them verbatim would make F-1 wrongly skip enduro
In Progress / Done tasks (regression of ORCH-053/060). So they are
included ONLY when DISTINCT from the project's base working statuses
(i.e. actually created as separate statuses): enduro collapses them to {}
-> zero regress; orchestrator keeps three real statuses -> BR-13.
**Never-raise, conservative fallback.** Any error / unresolved project /
missing state -> return ``True`` (treat as "possibly blocked" -> skip):
@@ -219,7 +241,22 @@ class Reconciler:
cur = fetch_issue_state(issue_id, pid)
if cur is None:
return True # Plane unreachable / no state -> conservative skip
return cur in {states.get("blocked"), states.get("needs_input")}
# ORCH-066 BR-13: active orchestrator waits, minus base working
# statuses so aliased (enduro) keys never widen the skip-set.
base_working = {
states.get(k) for k in (
"backlog", "todo", "in_progress", "in_review", "review",
"architecture", "development", "testing",
"approved", "rejected", "done",
)
}
extra_waits = {
states.get("awaiting_deploy"),
states.get("deploying"),
states.get("monitoring"),
} - base_working - {None}
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
return cur in skip_set
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(
f"reconciler Guard 2: blocked-check failed for task "
@@ -241,24 +278,52 @@ class Reconciler:
def _reconcile_plane_project(self, proj) -> None:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
# ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was
# In Progress). On a project without that status, `to_analyse` aliases to
# the project's own `in_progress` UUID, so enduro behaviour is identical
# (and `list_issues_by_state` deduplicates the uuid via its internal set).
states = get_project_states(pid)
in_progress = states["in_progress"]
# ORCH-066 (AC-19): start/resume trigger is `To Analyse`.
to_analyse = states["to_analyse"]
# ORCH-068 D1: {uuid -> group} from the SAME cache record (no extra
# fetch); empty when the API was unreachable -> per-issue fallback by key.
groups = get_project_state_groups(pid)
approved = states["approved"]
rejected = states["rejected"]
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
issues = list_issues_by_state(pid, [to_analyse, approved, rejected])
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected
issue, pid, to_analyse, approved, rejected, states, groups
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
f"reconciler F-2: issue {issue.get('id')} failed: {e}"
)
def _is_terminal_state(
self, state_uuid: str, states: dict, groups: dict
) -> bool:
"""ORCH-068 D1: is ``state_uuid`` a terminal (completed/cancelled) state?
Primary discriminator is the Plane **state group** (project-independent,
robust to UUID aliasing after status renames): ``group`` in
``{completed, cancelled}`` -> terminal. When the group is unavailable
(API gave no ``group`` / we fell back to ``_DEFAULT_STATES``), fall back
to the logical terminal keys ``done`` / ``cancelled``.
"""
if not state_uuid:
return False
grp = groups.get(state_uuid)
if grp:
return grp in {"completed", "cancelled"}
# Fallback (group unknown): logical terminal keys for this project.
return state_uuid in {states.get("done"), states.get("cancelled")}
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
to_analyse: str, approved: str, rejected: str,
states: dict, groups: dict,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
@@ -266,6 +331,15 @@ class Reconciler:
state = issue.get("state")
new_state = state.get("id") if isinstance(state, dict) else state
# ORCH-068 D1: a terminal issue (Done / Cancelled) is fully in sync by
# definition -> never actionable. Excluded per-issue (not by narrowing
# `wanted`) because UUID aliasing can make a terminal uuid collide with
# an actionable one — only the state GROUP disentangles them. Restores
# the silence-when-in-sync invariant (AC-1/AC-2).
if self._is_terminal_state(new_state, states, groups):
self.skipped_terminal_total += 1
return
# Grace ("lost, not merely delayed"): use the issue's own updated_at age.
# A missing/unparseable timestamp is treated as old enough (the active-job
# guard + atomic create-claim still prevent doubling).
@@ -288,20 +362,50 @@ class Reconciler:
"description_stripped": issue.get("description_stripped", ""),
}
if new_state == in_progress and task is None:
# In Progress without a task -> start the pipeline (lost start webhook).
if new_state == to_analyse and task is None:
# To Analyse without a task -> start the pipeline (lost start webhook).
# ORCH-068 D2: confirm a REAL change (the task now exists) before
# announcing — a no-op dispatch stays silent.
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(issue_id, "analysis")
if get_task_by_plane_id(issue_id) is not None:
self._note_unblock(issue_id, "analysis", new_state)
elif new_state == to_analyse and task is not None:
# To Analyse with an existing (idle) task -> resume the analyst from
# Needs Input (lost resume webhook). handle_status_start applies its
# own busy-guard / start-vs-resume fork.
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"], new_state)
elif new_state == approved and task is not None:
# Approved but the stage never advanced -> replay the verdict.
stage_before = task["stage"]
self._dispatch(handle_verdict, issue_data, project_id, approved=True)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
if self._stage_changed(issue_id, stage_before):
self._note_unblock(
task.get("work_item_id") or issue_id, stage_before, new_state
)
elif new_state == rejected and task is not None:
# Rejected but never rolled back -> replay the verdict.
stage_before = task["stage"]
self._dispatch(handle_verdict, issue_data, project_id, approved=False)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
if self._stage_changed(issue_id, stage_before):
self._note_unblock(
task.get("work_item_id") or issue_id, stage_before, new_state
)
# else: everything is in sync -> silence (AC-10).
@staticmethod
def _stage_changed(issue_id: str, stage_before: str) -> bool:
"""ORCH-068 D2: did the dispatched handler actually move the stage?
Re-reads the task after ``_dispatch`` and compares to the captured
``stage_before``. A no-op replay (the task was already in the target
state) leaves the stage unchanged -> no unblock notification.
"""
after = get_task_by_plane_id(issue_id)
stage_after = after["stage"] if after else stage_before
return stage_after != stage_before
@staticmethod
def _dispatch(coro_fn, *args, **kwargs) -> None:
"""Run an async plane handler from this sync thread.
@@ -314,12 +418,27 @@ class Reconciler:
asyncio.run(coro_fn(*args, **kwargs))
# -- observability (F-4) ----------------------------------------------
def _note_unblock(self, work_item_id: str, stage: str) -> None:
def _note_unblock(
self, work_item_id: str, stage: str, state_uuid: str | None = None
) -> None:
"""Record + announce that a stuck task was unblocked (AC-12).
Fires only on an actual state change (an advance / replayed transition),
never per idle tick, so it does not conflict with AC-9 / AC-10.
ORCH-068 (TR-3): an in-memory dedup guard keyed by ``issue_id ->
state_uuid`` suppresses a repeat notification for the same issue+state
if a future no-op path ever reaches here. ``state_uuid`` is the issue's
Plane state; ``work_item_id`` doubles as the issue id for the
pipeline-start case (which has no work item yet).
"""
dedup_key = work_item_id
if state_uuid is not None and self._unblock_dedup.get(dedup_key) == state_uuid:
self.deduped_total += 1
return
if state_uuid is not None:
self._unblock_dedup[dedup_key] = state_uuid
self.unblocked_total += 1
self.last_unblocked = work_item_id
logger.info(
@@ -328,7 +447,7 @@ class Reconciler:
if settings.reconcile_notify_unblock:
try:
send_telegram(
f"\U0001f527 reconciler: {work_item_id} {stage} "
f"\U0001f527 reconciler: {link_for(work_item_id)} {stage} "
f"разблокирована (потерян webhook)"
)
except Exception as e: # noqa: BLE001 - never break the tick
@@ -380,6 +499,9 @@ class Reconciler:
"last_run_ts": self.last_run_ts,
"unblocked_total": self.unblocked_total,
"last_unblocked": self.last_unblocked,
# ORCH-068 observability.
"skipped_terminal_total": self.skipped_terminal_total,
"deduped_total": self.deduped_total,
}

689
src/security_gate.py Normal file
View File

@@ -0,0 +1,689 @@
"""Security-gate core (ORCH-022): secret-scanning + dependency audit before merge.
Background
----------
The orchestrator is autonomous: the ``developer`` agent writes code with no human
filter. Before a task branch merges into ``main`` there was no automatic check for a
leaked secret (key / token / password / private key) or a vulnerable dependency
(known CVE). For the self-hosting ``orchestrator`` repo this is acute: one shared
prod instance serves every project from a shared DB, so a secret or CVE that slips
through one task lands in the prod of all projects (CLAUDE.md §self-hosting, §8).
This module provides the deterministic (no-LLM) primitives that the quality-gate
``check_security_gate`` (src/qg/checks.py) composes on the ``deploy-staging ->
deploy`` edge, **FIRST** among the edge sub-gates (BEFORE the merge-gate and
image-freshness), immediately before the deployer merges the PR (ADR-001 Р-1):
* ``scan_secrets`` -> run ``gitleaks`` over ``origin/main..HEAD`` (offline).
* ``audit_dependencies`` -> run ``pip-audit`` over ``requirements.txt`` (OSV/PyPI).
* ``classify_severity`` -> pure: map a CVE severity to block / warning.
* ``compute_verdict`` -> pure: combine findings + thresholds -> the artefact
frontmatter fields + a human-readable reason.
* ``write_security_report`` / ``parse_security_status`` -> write the
``17-security-report.md`` artefact and read its machine verdict back (single
source of truth: the gate returns exactly the frontmatter it wrote, AC-8).
* ``check_security_gate`` -> the orchestrating entry the QG wrapper delegates to.
Invariants (ADR-001 §7, never broken):
* **Secrets are unconditional** (BR-2): gitleaks is fully offline, so the "a
secret always blocks" guarantee does not depend on the network. A secret-scan
TOOL error is **fail-closed** (we cannot prove "no secret" -> FAIL).
* **Dependency audit is best-effort** (Р-3): an unreachable CVE feed degrades
**fail-open + a loud warning** by default (anti-loop, precedent ORCH-061);
``security_dep_audit_fail_closed`` flips it to strict.
* **never-raise**: any internal error -> ``(False, "<reason>")``; an exception
never escapes into ``advance_stage`` (AC-16).
* **Self-hosting safety** (AC-19): the gate only reads / scans / writes the
artefact. It never calls the deploy hook and never restarts the prod container.
This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily
``qg.checks.is_self_hosting_repo`` / ``notifications``; it never imports
``stage_engine``.
"""
import json
import logging
import os
import subprocess
from dataclasses import dataclass, field
from .config import settings
from .git_worktree import ensure_worktree, get_worktree_path
logger = logging.getLogger("orchestrator.security_gate")
# Bounded git timeout so a hung fetch never wedges the monitor-thread running the
# gate (the scan timeout itself comes from settings.security_scan_timeout_s).
_GIT_TIMEOUT = 60
# Severity ranking for the dependency block threshold. UNKNOWN / unrecognised is
# intentionally absent -> classified as "warning" (anti-loop, ADR-001 Р-4).
_SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
# ---------------------------------------------------------------------------
# Result containers (plain dataclasses, easy to build in tests)
# ---------------------------------------------------------------------------
@dataclass
class SecretScanResult:
"""Outcome of :func:`scan_secrets`.
status:
* ``"clean"`` -> no secret found.
* ``"found"`` -> ``findings`` lists the confirmed (non-allowlisted) secrets.
* ``"error"`` -> the scanner could not run (missing binary / timeout / rc>=2);
treated as **fail-closed** by :func:`compute_verdict` (BR-2).
"""
status: str = "clean"
findings: list = field(default_factory=list)
detail: str = ""
@dataclass
class DepAuditResult:
"""Outcome of :func:`audit_dependencies`.
status:
* ``"ok"`` -> the audit ran; ``findings`` may be empty or non-empty.
* ``"degraded"`` -> the CVE feed was unreachable / the tool failed; **fail-open**
by default (ADR-001 Р-3), surfaced as ``deps_audit_degraded: true``.
"""
status: str = "ok"
findings: list = field(default_factory=list)
detail: str = ""
# ---------------------------------------------------------------------------
# Conditionality (mirrors _merge_gate_applies / image_freshness_applies)
# ---------------------------------------------------------------------------
def security_gate_applies(repo: str) -> bool:
"""Whether the security-gate is REAL for this repo (conditional rollout).
Mirrors the ORCH-35 / ORCH-43 / ORCH-58 pattern:
* ``security_gate_enabled=False`` -> always False (kill-switch; pipeline is
1:1 as before ORCH-022 for everyone).
* ``security_gate_repos`` (CSV) non-empty -> real only for the listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
Never raises (AC-16): any error -> False (the safe no-op default).
"""
try:
if not settings.security_gate_enabled:
return False
raw = (settings.security_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
# Lazy import keeps this module a leaf (no qg import 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("security_gate_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# Secret-scanning (gitleaks, offline) — FR-1 / AC-1..AC-3
# ---------------------------------------------------------------------------
def _gitleaks_config_path(worktree: str) -> str | None:
"""Versioned ``.gitleaks.toml`` at the repo root (BR-13), or None if absent."""
cfg = os.path.join(worktree, ".gitleaks.toml")
return cfg if os.path.isfile(cfg) else None
def _mask(secret: str) -> str:
"""Mask a matched secret so the artefact never re-leaks it verbatim."""
s = (secret or "").strip()
if len(s) <= 8:
return "****"
return f"{s[:4]}{s[-2:]}"
def parse_gitleaks_report(text: str) -> list:
"""Pure parser for the gitleaks JSON report -> a list of finding dicts.
Each finding: ``{"file", "rule", "line", "match"}`` (the match is MASKED).
Tolerates an empty / non-JSON / non-list body (returns ``[]``); never raises.
"""
try:
data = json.loads(text or "[]")
except (ValueError, TypeError):
return []
if not isinstance(data, list):
return []
out = []
for item in data:
if not isinstance(item, dict):
continue
out.append(
{
"file": item.get("File") or item.get("file") or "?",
"rule": item.get("RuleID") or item.get("Description") or "secret",
"line": item.get("StartLine") or item.get("startLine") or 0,
"match": _mask(item.get("Secret") or item.get("Match") or ""),
}
)
return out
def scan_secrets(repo: str, branch: str) -> SecretScanResult:
"""Scan ``origin/main..HEAD`` of the task branch for secrets with ``gitleaks``.
Offline (BR-2): gitleaks rules are local, so the "a secret always blocks"
guarantee never depends on the network. Scanning the ``origin/main..HEAD``
range covers exactly the commits this task adds (and that will land in
``main``), and — because it runs BEFORE the merge-gate rebase — does not blame
the task for a secret introduced by a parallel update of ``main`` (ADR-001 Р-1).
Exit-code contract (07-infra-requirements.md I-1): 0 = clean, 1 = secrets
found, >=2 = tool error. A tool error / missing binary / timeout -> ``"error"``
(fail-closed downstream). Never raises (AC-16).
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
return SecretScanResult(status="error", detail=f"worktree error: {e}")
# Refresh origin/main so the origin/main..HEAD range is meaningful. Best-effort:
# a fetch failure does not abort the scan (gitleaks still scans whatever range
# it can resolve); the scan itself is the security-critical step.
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_GIT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("scan_secrets: fetch origin/main failed for %s/%s: %s", repo, branch, e)
report_path = os.path.join(wt, ".gitleaks-report.json")
cmd = [
"gitleaks", "detect",
"--source", wt,
"--log-opts", "origin/main..HEAD",
"--report-format", "json",
"--report-path", report_path,
"--exit-code", "1",
"--no-banner",
]
cfg = _gitleaks_config_path(wt)
if cfg:
cmd += ["--config", cfg]
timeout = settings.security_scan_timeout_s
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return SecretScanResult(status="error", detail=f"gitleaks timeout after {timeout}s")
except FileNotFoundError:
# Missing binary -> fail-closed (we cannot prove the branch is secret-free).
return SecretScanResult(status="error", detail="gitleaks binary not found")
except (subprocess.SubprocessError, OSError) as e:
return SecretScanResult(status="error", detail=f"gitleaks error: {e}")
finally:
# The report file is transient scratch inside the worktree; remove it after
# reading so it is never committed/scanned on a later pass.
report_text = ""
try:
if os.path.isfile(report_path):
with open(report_path, "r", encoding="utf-8") as f:
report_text = f.read()
os.remove(report_path)
except OSError:
report_text = ""
if r.returncode == 0:
return SecretScanResult(status="clean", detail="no secrets found")
if r.returncode == 1:
findings = parse_gitleaks_report(report_text) or parse_gitleaks_report(r.stdout)
if not findings:
# rc=1 with no parseable findings -> still treat as found (fail-closed).
findings = [{"file": "?", "rule": "secret", "line": 0, "match": "****"}]
return SecretScanResult(
status="found", findings=findings, detail=f"{len(findings)} secret(s) found"
)
# rc >= 2 (or any other) -> tool error -> fail-closed.
tail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:]
return SecretScanResult(status="error", detail=f"gitleaks rc={r.returncode}: {tail}")
# ---------------------------------------------------------------------------
# Dependency audit (pip-audit, OSV/PyPI) — FR-2 / AC-4..AC-7
# ---------------------------------------------------------------------------
def parse_pip_audit_report(text: str) -> list:
"""Pure parser for the ``pip-audit -f json`` report -> a list of finding dicts.
Each finding: ``{"package", "version", "id", "severity", "fix"}``. pip-audit's
default JSON rarely carries a CVSS severity (OSV advisories often omit it), so a
missing severity is reported as ``"UNKNOWN"`` (classified as a warning, never an
auto-block — ADR-001 Р-4 anti-loop). Tolerates both the modern
``{"dependencies": [...]}`` shape and a bare list; never raises.
"""
try:
data = json.loads(text or "{}")
except (ValueError, TypeError):
return []
if isinstance(data, dict):
deps = data.get("dependencies", data.get("vulnerabilities", []))
elif isinstance(data, list):
deps = data
else:
return []
out = []
for dep in deps or []:
if not isinstance(dep, dict):
continue
name = dep.get("name") or dep.get("package") or "?"
version = dep.get("version") or "?"
for v in dep.get("vulns", dep.get("vulnerabilities", [])) or []:
if not isinstance(v, dict):
continue
sev = _extract_severity(v)
fix = v.get("fix_versions") or v.get("fixed_in") or []
aliases = v.get("aliases") or []
vuln_id = v.get("id") or (aliases[0] if aliases else "?")
out.append(
{
"package": name,
"version": version,
"id": vuln_id,
"severity": sev,
"fix": ", ".join(fix) if isinstance(fix, list) else str(fix),
}
)
return out
def _extract_severity(vuln: dict) -> str:
"""Best-effort severity extraction from a pip-audit vuln record -> UPPER token.
pip-audit JSON may carry severity in different shapes depending on the advisory
source; when none is present we return ``"UNKNOWN"`` (warning, never a block).
"""
raw = vuln.get("severity")
if isinstance(raw, str) and raw.strip():
return raw.strip().upper()
if isinstance(raw, list) and raw:
first = raw[0]
if isinstance(first, dict):
val = first.get("severity") or first.get("score") or first.get("type")
if val:
return str(val).strip().upper()
elif first:
return str(first).strip().upper()
return "UNKNOWN"
def audit_dependencies(repo: str, branch: str) -> DepAuditResult:
"""Audit the branch's ``requirements.txt`` for known CVEs with ``pip-audit``.
The advisory source is OSV/PyPI -> it needs the network. Per ADR-001 Р-3 an
unreachable feed / tool failure degrades **fail-open** by default (status
``"degraded"``), so a transient network problem on the prod instance never
produces a false rollback loop (precedent ORCH-061). The ``"degraded"`` state
is surfaced loudly (``deps_audit_degraded: true`` + warning log + Telegram).
Returns a :class:`DepAuditResult`. Never raises (AC-16).
"""
try:
wt = get_worktree_path(repo, branch)
if not os.path.isdir(wt):
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
return DepAuditResult(status="degraded", detail=f"worktree error: {e}")
req = os.path.join(wt, "requirements.txt")
if not os.path.isfile(req):
# Python-only v1 (A3): no manifest -> nothing to audit (not a degrade).
return DepAuditResult(status="ok", detail="no requirements.txt to audit")
cmd = ["pip-audit", "-r", req, "-f", "json", "--progress-spinner", "off"]
timeout = settings.security_scan_timeout_s
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return DepAuditResult(status="degraded", detail=f"pip-audit timeout after {timeout}s")
except FileNotFoundError:
# Missing binary -> degrade (dep-audit is best-effort, not unconditional).
return DepAuditResult(status="degraded", detail="pip-audit binary not found")
except (subprocess.SubprocessError, OSError) as e:
return DepAuditResult(status="degraded", detail=f"pip-audit error: {e}")
# pip-audit exits 0 (no vulns) or 1 (vulns found) with valid JSON on stdout. A
# network/feed error produces non-JSON output (and often a non-zero rc) -> if
# we cannot parse the JSON we degrade fail-open rather than block falsely.
out = (r.stdout or "").strip()
if not out:
if r.returncode == 0:
return DepAuditResult(status="ok", detail="no vulnerabilities")
tail = (r.stderr or "").strip()[-200:]
return DepAuditResult(status="degraded", detail=f"pip-audit no output (rc={r.returncode}): {tail}")
try:
json.loads(out)
except ValueError:
tail = (r.stderr or "").strip()[-200:]
return DepAuditResult(status="degraded", detail=f"pip-audit feed unavailable: {tail}")
findings = parse_pip_audit_report(out)
return DepAuditResult(status="ok", findings=findings, detail=f"{len(findings)} vuln(s)")
# ---------------------------------------------------------------------------
# Pure classification + verdict (FR-2/FR-3/Р-4) — the core of the unit tests
# ---------------------------------------------------------------------------
def classify_severity(severity: str, block_threshold: str) -> str:
"""Pure: classify a CVE severity against the block threshold -> token.
Returns ``"block"`` when ``severity >= block_threshold`` in CRITICAL > HIGH >
MEDIUM > LOW order, else ``"warning"``. An UNKNOWN / unrecognised severity is
ALWAYS ``"warning"`` (never an auto-block — anti-loop, ADR-001 Р-4). Never
raises.
"""
sev = (severity or "").upper().strip()
thr = (block_threshold or "HIGH").upper().strip()
sev_rank = _SEVERITY_ORDER.get(sev)
thr_rank = _SEVERITY_ORDER.get(thr, _SEVERITY_ORDER["HIGH"])
if sev_rank is None:
return "warning"
return "block" if sev_rank >= thr_rank else "warning"
def compute_verdict(
secret_result: SecretScanResult,
dep_result: DepAuditResult,
*,
secrets_block: bool,
dep_block_severity: str,
dep_fail_closed: bool,
) -> dict:
"""Pure: combine scan results + thresholds into the artefact's machine fields.
Returns a dict with the frontmatter fields (``security_status``,
``secrets_found``, ``deps_blocking``, ``deps_warning``, ``deps_audit_degraded``),
a one-line ``reason`` summary, and the categorised finding lists for the body.
Decision (ADR-001 Р-4):
* secret-scan ERROR -> FAIL (fail-closed; BR-2 secrets guarantee is unconditional).
* any secret found AND ``secrets_block`` -> FAIL.
* any dependency at/over ``dep_block_severity`` -> FAIL (``deps_blocking``).
* MEDIUM/LOW/UNKNOWN deps -> warning only (``deps_warning``), never block.
* feed degraded -> warning by default; FAIL only when ``dep_fail_closed``.
Never raises.
"""
secret_scan_error = secret_result.status == "error"
secret_findings = list(secret_result.findings) if secret_result.status == "found" else []
secrets_found = len(secret_findings)
deps_audit_degraded = dep_result.status == "degraded"
blocking_findings = []
warning_findings = []
for f in dep_result.findings or []:
if classify_severity(f.get("severity", "UNKNOWN"), dep_block_severity) == "block":
blocking_findings.append(f)
else:
warning_findings.append(f)
reasons = []
fail = False
if secret_scan_error:
fail = True
reasons.append(f"secret scan error (fail-closed): {secret_result.detail}")
if secrets_block and secrets_found > 0:
fail = True
names = ", ".join(
f"{x.get('rule')} in {x.get('file')}:{x.get('line')}" for x in secret_findings
)
reasons.append(f"{secrets_found} secret(s): {names}")
if blocking_findings:
fail = True
names = ", ".join(
f"{x.get('package')} {x.get('version')} {x.get('id')} ({x.get('severity')})"
for x in blocking_findings
)
reasons.append(f"{len(blocking_findings)} blocking CVE(s): {names}")
if deps_audit_degraded and dep_fail_closed:
fail = True
reasons.append(f"dep-audit feed unavailable (fail-closed): {dep_result.detail}")
status = "FAIL" if fail else "PASS"
if reasons:
reason = "; ".join(reasons)
else:
extra = " (dep-audit degraded — warning only)" if deps_audit_degraded else ""
reason = f"clean: {secrets_found} secrets, {len(blocking_findings)} blocking CVE(s){extra}"
return {
"security_status": status,
"secrets_found": secrets_found,
"secret_scan_error": secret_scan_error,
"deps_blocking": len(blocking_findings),
"deps_warning": len(warning_findings),
"deps_audit_degraded": deps_audit_degraded,
"reason": reason,
"secret_findings": secret_findings,
"blocking_findings": blocking_findings,
"warning_findings": warning_findings,
}
# ---------------------------------------------------------------------------
# Artefact: write the report, read the machine verdict back (FR-3 / AC-8..AC-10)
# ---------------------------------------------------------------------------
def _report_rel(work_item_id: str) -> str:
return f"docs/work-items/{work_item_id}/17-security-report.md"
def _report_path(repo: str, work_item_id: str, branch: str) -> str:
"""Absolute path of 17-security-report.md inside the task worktree."""
try:
wt = get_worktree_path(repo, branch)
if not os.path.isdir(wt):
wt = ensure_worktree(repo, branch)
except Exception: # noqa: BLE001 - never-raise; fall back to shared clone
wt = os.path.join(settings.repos_dir, repo)
return os.path.join(wt, _report_rel(work_item_id))
def _bool_yaml(v: bool) -> str:
return "true" if v else "false"
def render_security_report(work_item_id: str, fields: dict) -> str:
"""Pure: render the 17-security-report.md content (frontmatter + body) from the
fields produced by :func:`compute_verdict`. Never raises."""
def _secret_lines():
items = fields.get("secret_findings") or []
if not items:
return "- None"
return "\n".join(
f"- `{x.get('file')}:{x.get('line')}` — {x.get('rule')} (match `{x.get('match')}`)"
for x in items
)
def _dep_lines(key):
items = fields.get(key) or []
if not items:
return "- None"
return "\n".join(
f"- `{x.get('package')}=={x.get('version')}` — {x.get('id')} "
f"severity={x.get('severity')} fix={x.get('fix') or 'n/a'}"
for x in items
)
return (
"---\n"
f"security_status: {fields.get('security_status', 'FAIL')}\n"
f"secrets_found: {int(fields.get('secrets_found', 0))}\n"
f"deps_blocking: {int(fields.get('deps_blocking', 0))}\n"
f"deps_warning: {int(fields.get('deps_warning', 0))}\n"
f"deps_audit_degraded: {_bool_yaml(bool(fields.get('deps_audit_degraded', False)))}\n"
"---\n"
f"# Security Report — {work_item_id}\n\n"
"Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + "
"dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.\n\n"
"## Verdict\n"
f"{fields.get('reason', '')}\n\n"
"## Secrets\n"
f"{_secret_lines()}\n\n"
"## Dependencies (blocking)\n"
f"{_dep_lines('blocking_findings')}\n\n"
"## Dependencies (warning)\n"
f"{_dep_lines('warning_findings')}\n"
)
def write_security_report(repo: str, work_item_id: str, branch: str, fields: dict) -> str:
"""Write 17-security-report.md into the task worktree; return its path.
Best-effort/never-raise: a write error is logged and the path is still returned
(the caller's read-back then fails closed). The artefact body is human-readable;
the machine verdict lives ONLY in the YAML frontmatter (canon)."""
path = _report_path(repo, work_item_id, branch)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(render_security_report(work_item_id, fields))
except OSError as e:
logger.error("write_security_report error for %s/%s: %s", repo, work_item_id, e)
return path
def parse_security_status(content: str) -> tuple[bool, str]:
"""Map a 17-security-report.md body to a quality-gate verdict by reading ONLY
the machine-readable ``security_status:`` YAML frontmatter — never the prose.
Mirrors ``_parse_deploy_status`` / ``_parse_staging_status`` (canon: machine
verdict only from frontmatter, AC-8). The negative token (FAIL) is authoritative
(checked first). Returns:
* ``security_status: PASS`` -> ``(True, "Security status: PASS")``
* ``security_status: FAIL`` -> ``(False, "Security status: FAIL")``
* missing field / no frontmatter / bad YAML -> ``(False, <reason>)`` (fail-closed
on the verdict read, AC-9).
"""
import yaml
status = None
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
try:
fm = yaml.safe_load(parts[1]) or {}
except yaml.YAMLError as e:
return False, f"Invalid YAML frontmatter in security report: {e}"
if isinstance(fm, dict):
status = str(fm.get("security_status", "")).upper().strip()
if status == "FAIL":
return False, "Security status: FAIL"
if status == "PASS":
return True, "Security status: PASS"
return False, f"No machine-readable security_status in frontmatter (got: {status!r})"
def extract_security_findings(report_path: str) -> str:
"""ORCH-046: best-effort verbatim excerpt of the report's finding sections for
embedding into the developer's ``task_desc`` on a rollback.
Pulls the ``## Verdict`` + ``## Secrets`` + ``## Dependencies (blocking)``
sections so the developer sees the must-fix substance directly (not just a
link). Contract «never raise»: any error / missing file -> ``""`` (the caller
then falls back to the reason + link). Mirrors ``review_parse`` defensiveness.
"""
try:
if not os.path.isfile(report_path):
return ""
with open(report_path, "r", encoding="utf-8") as f:
content = f.read()
# Drop the frontmatter; keep the human body.
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2]
wanted = ("## Verdict", "## Secrets", "## Dependencies (blocking)")
lines = content.splitlines()
out = []
keep = False
for ln in lines:
if ln.startswith("## "):
keep = any(ln.startswith(w) for w in wanted)
if keep:
out.append(ln)
excerpt = "\n".join(out).strip()
return excerpt[:1500]
except Exception as e: # noqa: BLE001 - never-raise (ORCH-046 defensive)
logger.warning("extract_security_findings error for %s: %s", report_path, e)
return ""
# ---------------------------------------------------------------------------
# Orchestrating entry — delegated to by qg.checks.check_security_gate
# ---------------------------------------------------------------------------
def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-022 security-gate on the deploy-staging -> deploy edge, run FIRST.
Deterministic, no LLM. Algorithm (ADR-001 Р-1/Р-5):
1. Conditionality: ``security_gate_enabled=False`` -> ``(True, "...disabled")``;
a repo the gate is not real for -> ``(True, "security-gate N/A for <repo>")``.
2. ``scan_secrets`` (offline) + ``audit_dependencies`` (best-effort).
3. ``compute_verdict`` -> write ``17-security-report.md`` -> read the verdict
BACK via ``parse_security_status`` (single source of truth: the returned
verdict == the artefact frontmatter, AC-8).
4. FAIL -> ``(False, reason)`` (engine rolls back to ``development``); PASS ->
``(True, reason)`` (engine proceeds to the merge-gate).
A degraded dep-audit on a PASS is surfaced loudly (Telegram + log) without
failing the gate (ADR-001 Р-3). Never-raise (AC-16): any internal error ->
``(False, "<reason>")``; an exception never escapes into ``advance_stage``.
"""
try:
if not settings.security_gate_enabled:
return True, "security-gate disabled"
if not security_gate_applies(repo):
return True, f"security-gate N/A for {repo}"
secret_result = scan_secrets(repo, branch)
dep_result = audit_dependencies(repo, branch)
fields = compute_verdict(
secret_result,
dep_result,
secrets_block=settings.security_secrets_block,
dep_block_severity=settings.security_dep_block_severity,
dep_fail_closed=settings.security_dep_audit_fail_closed,
)
path = write_security_report(repo, work_item_id, branch, fields)
# Read the machine verdict back from the artefact we just wrote — so the
# returned (bool, reason) is guaranteed == the YAML frontmatter (AC-8).
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as e:
return False, f"cannot read security report (fail-closed): {e}"
ok, _verdict = parse_security_status(content)
# Surface a degraded dep-audit loudly even when the gate passes (Р-3 / BR-11).
if fields.get("deps_audit_degraded"):
logger.warning(
"security-gate %s/%s: dep-audit DEGRADED (fail-%s): %s",
repo, work_item_id,
"closed" if settings.security_dep_audit_fail_closed else "open",
dep_result.detail,
)
try:
from .notifications import send_telegram, link_for
send_telegram(
f"⚠️ {link_for(work_item_id)}: dep-audit недоступен фид CVE "
f"({dep_result.detail}). "
+ ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed
else "Гейт fail-open → warning (секреты проверены оффлайн).")
)
except Exception as e: # noqa: BLE001 - telegram best-effort
logger.warning("security-gate degraded telegram failed: %s", e)
if ok:
logger.info("security-gate passed for %s/%s: %s", repo, work_item_id, fields["reason"])
return True, f"security clean ({fields['reason']})"
return False, fields["reason"]
except Exception as e: # noqa: BLE001 - never-raise contract (AC-16)
logger.error("check_security_gate error for %s/%s: %s", repo, branch, e)
return False, f"security-gate error: {e}"

View File

@@ -349,3 +349,66 @@ def write_deploy_log(repo: str, work_item_id: str, branch: str, exit_code, statu
except (subprocess.SubprocessError, OSError) as e:
logger.warning("write_deploy_log: git commit/push best-effort failed: %s", e)
return True
def record_merged_to_main(repo: str, work_item_id: str, branch: str, merged: bool) -> bool:
"""Stamp ``merged_to_main: true|false`` into 14-deploy-log.md frontmatter (ORCH-071).
Machine-readable observability for the merge-verify under-gate. ONLY the
``merged_to_main:`` line is added/updated inside the YAML frontmatter block; the
``deploy_status:`` field is left untouched, so the ``check_deploy_status`` /
``_parse_deploy_status`` parsing contract is unchanged (TRZ §6 / AC §5).
Best-effort and idempotent: a missing log or any I/O error is logged and
swallowed. 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.warning("record_merged_to_main: worktree error for %s/%s: %s", repo, branch, e)
return False
path = os.path.join(wt, rel)
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
logger.info("record_merged_to_main: no deploy log at %s (skip)", path)
return False
except OSError as e:
logger.warning("record_merged_to_main: read error at %s: %s", path, e)
return False
value = "true" if merged else "false"
if not content.startswith("---"):
# No frontmatter to amend — do not fabricate one (keep the contract minimal).
logger.info("record_merged_to_main: no frontmatter in %s (skip)", path)
return False
parts = content.split("---", 2)
if len(parts) < 3:
return False
fm_lines = parts[1].splitlines()
new_lines = []
replaced = False
for ln in fm_lines:
if ln.strip().lower().startswith("merged_to_main:"):
new_lines.append(f"merged_to_main: {value}")
replaced = True
else:
new_lines.append(ln)
if not replaced:
# Insert before the closing of the frontmatter block (append to the body).
if new_lines and new_lines[0] == "":
new_lines = new_lines[1:]
new_lines.append(f"merged_to_main: {value}")
new_fm = "\n".join(new_lines)
new_content = "---\n" + new_fm.strip("\n") + "\n---" + parts[2]
try:
with open(path, "w", encoding="utf-8") as f:
f.write(new_content)
except OSError as e:
logger.warning("record_merged_to_main: write error at %s: %s", path, e)
return False
return True

View File

@@ -34,6 +34,7 @@ from .db import get_db, update_task_stage, enqueue_job
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 .security_gate import extract_security_findings
from .qg.checks import QG_CHECKS
from . import merge_gate
from . import self_deploy
@@ -43,6 +44,7 @@ from .notifications import (
notify_qg_failure,
notify_approve_requested,
send_telegram,
link_for,
)
from .plane_sync import (
notify_stage_change as plane_notify_stage,
@@ -53,6 +55,10 @@ from .plane_sync import (
set_issue_in_progress,
set_issue_blocked,
set_issue_done,
set_issue_analysis,
set_issue_awaiting_deploy,
set_issue_deploying,
set_issue_monitoring,
)
from .config import settings
@@ -297,6 +303,18 @@ def advance_stage(
# 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":
# --- ORCH-022 security sub-gate (deploy-staging -> deploy edge) -----
# Run FIRST among the edge sub-gates (BEFORE the merge-gate and the
# image-freshness rebuild): it is cheap (read-only scan) and we want to
# fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic:
# gitleaks (offline secret-scan) + pip-audit (CVE audit). FAIL -> rollback
# to development + developer-retry (cap MAX_DEVELOPER_RETRIES). It owns
# the outcome on intervention (mirrors the merge-gate / image-freshness).
if _handle_security_gate(
task_id, current_stage, repo, work_item_id, branch, agent, result
):
return result
if _handle_merge_gate(
task_id, current_stage, repo, work_item_id, branch, agent, result
):
@@ -329,6 +347,22 @@ def advance_stage(
)
return result
# --- ORCH-071 merge-verify under-gate (deploy -> done edge) ----------
# The SINGLE choke-point that gates EVERY path into terminal `done`
# (finalizer Phase C, reconciler F-1, job-reaper re-drive) on a CONFIRMED
# merge of the feature PR into `main`. For the self-hosting repo the
# deterministic self-deploy path never runs the LLM `deployer` that used to
# merge the PR, so a green deploy could reach `done` while the PR stayed
# `open` (phantom merge, ORCH-071). This врезка runs a deterministic
# merge-actor + post-deploy verification BEFORE update_task_stage; if the
# merge is not confirmed it HOLDs (alert, NO done, NO rollback) and returns
# without advancing. Not a STAGE_TRANSITIONS edge / registered QG — it is an
# edge sub-gate (mirrors the merge-gate врезка), so those contracts are
# unchanged. No-op for non-self repos / kill-switch off (1:1 prior behaviour).
if current_stage == "deploy" and next_stage == "done":
if _handle_merge_verify(task_id, 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
@@ -355,14 +389,28 @@ def advance_stage(
# here, so explicitly drive the Plane issue into the terminal Done state
# (PLANE_STATES['done'] — mapping unchanged) in addition to the
# stage-change comment above.
# ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy
# monitoring applies. For self-hosting (post_deploy_applies==True) the
# task enters a `Monitoring after Deploy` window, NOT terminal Done yet —
# the monitor finalises Done/Blocked (run_post_deploy_monitor). For
# non-self repos the behaviour is unchanged: terminal Done immediately.
# Where the `Monitoring after Deploy` status is absent, set_issue_monitoring
# degrades to the project's Done UUID -> identical to today.
if next_stage == "done" and work_item_id:
try:
set_issue_done(work_item_id)
logger.info(
f"Task {task_id}: deploy->done, Plane state forced to Done"
)
if post_deploy.post_deploy_applies(repo):
set_issue_monitoring(work_item_id)
logger.info(
f"Task {task_id}: deploy->done (self), Plane state -> "
f"Monitoring after Deploy (post-deploy window)"
)
else:
set_issue_done(work_item_id)
logger.info(
f"Task {task_id}: deploy->done, Plane state forced to Done"
)
except Exception as e:
logger.error(f"Task {task_id}: failed to set Plane Done: {e}")
logger.error(f"Task {task_id}: failed to set Plane terminal state: {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
@@ -564,7 +612,7 @@ def _handle_analysis_approved_flow(
author="analyst",
)
send_telegram(
f"\u2753 {work_item_id}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane."
f"\u2753 {link_for(work_item_id)}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane."
)
result.note = "analysis-needs-input"
return
@@ -623,7 +671,7 @@ def _handle_qg_failure_rollbacks(
)
else:
send_telegram(
f"\u26a0\ufe0f {work_item_id}: Max developer retries (3) reached. "
f"\u26a0\ufe0f {link_for(work_item_id)}: Max developer retries (3) reached. "
f"Manual intervention needed."
)
result.alerted = True
@@ -670,7 +718,7 @@ def _handle_qg_failure_rollbacks(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Tests still failing after 3 developer "
f"\U0001f6a8 {link_for(work_item_id)}: Tests still failing after 3 developer "
f"retries. Manual intervention needed."
)
result.alerted = True
@@ -686,7 +734,9 @@ def _handle_qg_failure_rollbacks(
notify_stage_change(task_id, current_stage, "analysis")
plane_notify_stage(work_item_id, current_stage, "analysis")
result.rolled_back_to = "analysis"
set_issue_in_progress(work_item_id)
# ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis`
# (degrades to In Progress where the status is not created).
set_issue_analysis(work_item_id)
with open(conflict_path, "r") as cf:
conflict_text = cf.read()[:500]
plane_add_comment(
@@ -725,7 +775,7 @@ def _handle_qg_failure_rollbacks(
author="deployer",
)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging FAILED ({reason}). "
f"\U0001f6a8 {link_for(work_item_id)}: Staging FAILED ({reason}). "
f"Rolled back to development. Needs fix."
)
result.alerted = True
@@ -769,7 +819,7 @@ def _handle_qg_failure_rollbacks(
author="deployer",
)
send_telegram(
f"\U0001f6a8 {work_item_id}: Deploy FAILED ({reason}). "
f"\U0001f6a8 {link_for(work_item_id)}: Deploy FAILED ({reason}). "
f"Rolled back to development. Needs fix."
)
result.alerted = True
@@ -865,7 +915,7 @@ def _handle_merge_gate_defer(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: merge-gate defer limit "
f"\U0001f6a8 {link_for(work_item_id)}: merge-gate defer limit "
f"({settings.merge_defer_max_attempts}) reached (merge-lock busy). "
f"Manual intervention needed."
)
@@ -920,7 +970,7 @@ def _handle_merge_gate_rollback(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Merge-gate still failing after "
f"\U0001f6a8 {link_for(work_item_id)}: Merge-gate still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
@@ -931,6 +981,93 @@ def _handle_merge_gate_rollback(
)
# ---------------------------------------------------------------------------
# ORCH-022: security sub-gate (secret-scan + dependency audit) on the
# deploy-staging -> deploy edge
# ---------------------------------------------------------------------------
def _handle_security_gate(
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
) -> bool:
"""Run check_security_gate on the deploy-staging -> deploy edge (ORCH-022).
Runs FIRST among the edge sub-gates — BEFORE the merge-gate and the
image-freshness rebuild — because it is a cheap read-only scan and we want to
fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic (no LLM):
gitleaks (offline secret-scan, fail-closed) + pip-audit (CVE audit, fail-open
degrade). The machine verdict lives in 17-security-report.md frontmatter.
Returns True if the gate INTERVENED (the caller must return without advancing):
* FAIL (secret found / blocking CVE / fail-closed) -> ROLLBACK to development
(+ developer retry, capped by MAX_DEVELOPER_RETRIES). No merge-lease release
here: the security-gate runs BEFORE the merge-gate, so the lease is not held
yet (distinct from the image-freshness rollback). The verbatim findings are
embedded into the developer's task_desc (ORCH-046 pattern, TC-17).
Returns False when the gate PASSED (clean, or N/A for a non-self repo with an
empty scope) so advance_stage proceeds to the merge-gate.
"""
passed, reason = _run_qg("check_security_gate", repo, work_item_id, branch)
if passed:
logger.info(f"Task {task_id}: security-gate passed ({reason})")
return False
result.qg_name = "check_security_gate"
result.qg_passed = False
result.qg_reason = reason
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)
notify_qg_failure(task_id, current_stage, "check_security_gate", reason)
plane_add_comment(
work_item_id,
f"❌ Security-гейт провален ({reason}). Откат на development. "
f"Developer нужен для фикса (секреты/уязвимые зависимости).",
author="deployer",
)
retry_count = _developer_retry_count(task_id)
if retry_count < MAX_DEVELOPER_RETRIES:
# ORCH-046: embed the verbatim findings into task_desc so the developer
# agent sees the must-fix substance directly (not just a link).
# extract_security_findings never raises; "" -> graceful link-only fallback.
report_ref = f"docs/work-items/{work_item_id}/17-security-report.md"
report_path = os.path.join(get_worktree_path(repo, branch), report_ref)
findings = extract_security_findings(report_path)
head = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: development\nNote: Security-гейт провален "
f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). "
f"Причина: {reason}."
)
if findings:
task_desc = (
f"{head}\nFindings:\n{findings}\n"
f"Полный контекст: {report_ref}"
)
else:
task_desc = f"{head} Fix findings in {report_ref}"
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}: security-gate FAILED, enqueued developer (job_id={new_job})"
)
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {link_for(work_item_id)}: Security-гейт still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
result.alerted = True
logger.error(
f"Task {task_id}: security-gate FAILED, rolled back deploy-staging -> "
f"development ({reason})"
)
return True
# ---------------------------------------------------------------------------
# ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge
# ---------------------------------------------------------------------------
@@ -996,7 +1133,7 @@ def _handle_image_freshness(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after "
f"\U0001f6a8 {link_for(work_item_id)}: Staging image freshness still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
@@ -1031,7 +1168,11 @@ def _handle_self_deploy_phase_a(
result.note = "self-deploy-approval-pending"
if work_item_id:
set_issue_in_review(work_item_id)
# ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`,
# which discharges `In Review` of the deploy-approval meaning (In Review
# stays for analyst BRD/review approve-pending only). Degrades to In Review
# where the status is not created.
set_issue_awaiting_deploy(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
@@ -1050,7 +1191,7 @@ def _handle_self_deploy_phase_a(
author="deployer",
)
send_telegram(
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя "
f"\U0001f7e1 {link_for(work_item_id)}: staging OK. Ждёт подтверждения ПРОД-деплоя "
f"(смените статус на «Confirm Deploy»)."
)
logger.info(
@@ -1085,13 +1226,17 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
"Повторите approve после устранения причины.",
author="deployer",
)
send_telegram(f"⚠️ {work_item_id}: прод-деплой не запустился: {msg}")
send_telegram(f"⚠️ {link_for(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())
)
# ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying`
# (degrades to In Progress where the status is not created).
if work_item_id:
set_issue_deploying(work_item_id)
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
@@ -1110,7 +1255,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
"Вердикт будет зафиксирован после health-check.",
author="deployer",
)
send_telegram(f"\U0001f680 {work_item_id}: прод-деплой стартовал. Жду результат.")
send_telegram(f"\U0001f680 {link_for(work_item_id)}: прод-деплой стартовал. Жду результат.")
logger.info(
f"Task {task_id}: self-deploy Phase B — detached deploy initiated, "
f"finalizer enqueued (job_id={new_job})"
@@ -1132,6 +1277,106 @@ def _deploy_finalize_defer_count(task_id: int) -> int:
return n
def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceResult) -> bool:
"""ORCH-071 merge-verify under-gate on the `deploy -> done` edge.
Returns:
* ``True`` -> INTERVENED (HOLD): the merge is NOT confirmed -> alert +
``set_issue_blocked`` (Plane non-terminal), task stays on `deploy`, NO
``done``, NO rollback to development (not-merged is an INFRA defect, not a
code fault -> ALERT-only, FR-3). The caller returns without advancing. A
later re-drive (reaper / reconciler / re-approve) re-evaluates and, once the
merge is fixed, lets the task advance to `done`.
* ``False`` -> the merge is CONFIRMED (or the under-gate does not apply for
this repo / kill-switch off) -> ``advance_stage`` proceeds to `done`
unchanged (happy-path AC-4 / AC-4b).
Steps (D5):
1. Conditionality (FR-5): not applicable -> return False (1:1 prior behaviour).
2. Resolve the validated SHA; run the deterministic merge-actor
``merge_gate.merge_pr`` (no-op if already merged, INV-5).
3. ``merge_gate.verify_merged_to_main`` -> confirmed?
* yes -> stamp ``merged_to_main: true``, return False (advance).
* no -> alert + Blocked + stamp ``merged_to_main: false``, return True (HOLD).
Wrapped never-raise (INV-1/AC-7): any internal error is treated as "not
confirmed" (HOLD + alert), never a propagated exception into ``advance_stage``.
"""
try:
if not merge_gate.merge_verify_applies(repo):
return False # non-self / kill-switch off -> behave exactly as before.
from . import image_freshness
sha = image_freshness.validated_revision(repo, branch)
# Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9).
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
logger.info(
f"Task {task_id}: merge-verify merge_pr -> ok={merged_ok} ({merge_msg})"
)
confirmed = merge_gate.verify_merged_to_main(repo, branch, sha)
if confirmed:
merge_gate.note_merge_verified()
try:
self_deploy.record_merged_to_main(repo, work_item_id, branch, True)
except Exception as e: # noqa: BLE001 - observability best-effort
logger.warning(f"Task {task_id}: record merged_to_main(true) failed: {e}")
logger.info(f"Task {task_id}: merge-verify CONFIRMED -> deploy->done allowed")
return False
# Not confirmed -> alert + HOLD (no done, no rollback).
merge_gate.note_not_merged_alert(work_item_id)
try:
self_deploy.record_merged_to_main(repo, work_item_id, branch, False)
except Exception as e: # noqa: BLE001 - observability best-effort
logger.warning(f"Task {task_id}: record merged_to_main(false) failed: {e}")
msg = (
f"deploy succeeded but not merged: {work_item_id} (repo={repo}, "
f"branch={branch}). `main` НЕ получил commit задачи — задача удержана "
f"на `deploy` (НЕ done). Нужно ручное вмешательство."
)
logger.warning(f"Task {task_id}: {msg}")
if work_item_id:
try:
set_issue_blocked(work_item_id)
except Exception as e: # noqa: BLE001 - never break the HOLD
logger.warning(f"Task {task_id}: set_issue_blocked failed: {e}")
try:
plane_add_comment(
work_item_id,
"\U0001f6a8 Deploy прошёл, но PR НЕ влит в `main` "
f"(merge: {merge_msg}). Задача удержана на `deploy` (НЕ done). "
"Нужно влить PR вручную и повторить approve.",
author="deployer",
)
except Exception as e: # noqa: BLE001 - never break the HOLD
logger.warning(f"Task {task_id}: plane not-merged comment failed: {e}")
try:
send_telegram(f"\U0001f6a8 {msg}")
except Exception as e: # noqa: BLE001 - never break the HOLD
logger.warning(f"Task {task_id}: not-merged telegram failed: {e}")
result.alerted = True
result.note = "merge-not-verified-hold"
result.advanced = False
return True
except Exception as e: # noqa: BLE001 - never-raise contract (INV-1/AC-7)
# Any internal error -> treat as "not confirmed" -> HOLD + alert, never crash.
logger.error(f"Task {task_id}: _handle_merge_verify error: {e}")
try:
merge_gate.note_not_merged_alert(work_item_id)
send_telegram(
f"\U0001f6a8 {link_for(work_item_id)}: ошибка merge-verify ({e}). "
f"Задача удержана на `deploy` (НЕ done)."
)
except Exception: # noqa: BLE001 - best-effort alert
pass
result.alerted = True
result.note = f"merge-verify-error: {e}"
result.advanced = False
return True
def run_deploy_finalizer(job: dict):
"""Phase C — deterministic finalizer (reserved-agent `deploy-finalizer`, no LLM).
@@ -1179,7 +1424,7 @@ def run_deploy_finalizer(job: dict):
if work_item_id:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: deploy result не появился после "
f"\U0001f6a8 {link_for(work_item_id)}: deploy result не появился после "
f"{settings.deploy_finalize_max_attempts} попыток. Нужно ручное вмешательство."
)
logger.error(
@@ -1200,7 +1445,7 @@ def run_deploy_finalizer(job: dict):
f"✅ Прод-деплой успешен (health-check OK, exit {code}).",
author="deployer",
)
send_telegram(f"{work_item_id}: прод-деплой успешен (exit {code}).")
send_telegram(f"{link_for(work_item_id)}: прод-деплой успешен (exit {code}).")
# Drive the EXISTING deploy contracts via the gate verdict we just wrote.
advance_stage(
@@ -1286,6 +1531,12 @@ def run_post_deploy_monitor(job: dict):
settings.post_deploy_window_s, checks_total, checks_failed,
)
post_deploy.mark_done(repo, work_item_id)
# ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done.
if work_item_id:
try:
set_issue_done(work_item_id)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}")
_notify_post_deploy(
work_item_id,
f"{work_item_id}: пост-деплой окно завершено чисто "
@@ -1326,6 +1577,15 @@ def run_post_deploy_monitor(job: dict):
f"self-hosting запрещён (BR-5).",
)
# ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for
# manual intervention. This is INDICATION ONLY — the tick NEVER restarts /
# rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5).
if work_item_id:
try:
set_issue_blocked(work_item_id)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}")
post_deploy.write_post_deploy_log(
repo, work_item_id, branch, post_deploy.DEGRADED, action_taken,
settings.post_deploy_window_s, checks_total, checks_failed,

View File

@@ -147,15 +147,21 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
return
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
# both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
# ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point),
# which discharges `In Progress` of its overloaded "start the pipeline"
# meaning. Fail-closed: on a project without the `To Analyse` status,
# `to_analyse` aliases to the project's own `in_progress` UUID, so moving an
# enduro issue to In Progress still triggers start/resume (AC-17).
proj_states = get_project_states(project_id)
# ORCH-059: the dedicated "Confirm Deploy" status is the prod-deploy trigger.
# fail-closed via .get — environments without the status (enduro / API
# fallback) resolve to None, so the branch simply never activates (no KeyError,
# no blind deploy). Checked before `approved` so the two gestures never alias.
confirm_state = proj_states.get("confirm_deploy")
if new_state == proj_states["in_progress"]:
# ORCH-066: start/resume trigger is `To Analyse` (human entry-point).
if new_state == proj_states["to_analyse"]:
await handle_status_start(data, project_id)
elif confirm_state and new_state == confirm_state:
await handle_confirm_deploy(data, project_id)
@@ -281,9 +287,14 @@ async def handle_status_start(data: dict, project_id: str = ""):
)
job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id)
logger.info(
f"Task {task_id}: returned to In Progress (Needs Input answered), "
f"Task {task_id}: returned to To Analyse (Needs Input answered), "
f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})"
)
# ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is
# re-indicated as `Analysis`; other stages keep their own indication.
if current_stage == "analysis":
from ..plane_sync import set_issue_analysis as _set_analysis
_set_analysis(work_item_id)
try:
_add_comment(
work_item_id,
@@ -584,6 +595,10 @@ async def start_pipeline(data: dict, project_id: str = ""):
)
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
# ORCH-066 (AC-3): indicate the analysis stage with the dedicated
# `Analysis` status (degrades to In Progress where it is not created).
from ..plane_sync import set_issue_analysis as _set_analysis
_set_analysis(work_item_id, plane_project_id)
# Post start comment to Plane
from ..plane_sync import add_comment as _add_comment
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
@@ -625,9 +640,11 @@ async def _rollback_stage(
(via the existing rollback notify + an enqueue of the prev-stage agent).
"""
if current_stage == "analysis":
# Already in analysis — just relaunch analyst with rejection reason
from ..plane_sync import set_issue_in_progress
set_issue_in_progress(work_item_id)
# Already in analysis — just relaunch analyst with rejection reason.
# ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the
# status is not created).
from ..plane_sync import set_issue_analysis
set_issue_analysis(work_item_id)
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. "

View File

@@ -75,3 +75,23 @@ def _reset_webhook_secrets(monkeypatch):
if db_path_env:
monkeypatch.setattr(db_mod.settings, "db_path", db_path_env, raising=False)
yield
@pytest.fixture(autouse=True)
def _disable_merge_verify(monkeypatch):
"""ORCH-071: disable the merge-verify under-gate by default in ALL tests.
The under-gate (deploy -> done) runs a deterministic merge-actor + a
post-deploy merge verification that make REAL Gitea/git calls. Leaving it ON
by default would (a) reach the network from unrelated deploy->done tests and
(b) make them pass/fail by ACCIDENT depending on whether the live Gitea still
has the historical PR merged (a hidden CI flake). We therefore default it to
its documented kill-switch OFF state (``merge_verify_enabled=False`` == 1:1
pre-ORCH-071 behaviour). Tests that specifically target the under-gate
(test_merge_verify / test_deploy_finalizer_merge_gate / test_merge_actor /
test_deploy_restart_merge_recovery) re-enable it via their own monkeypatch
AFTER this autouse fixture, scoping the feature ON to just those tests.
"""
from src import config as _cfg
monkeypatch.setattr(_cfg.settings, "merge_verify_enabled", False, raising=False)
yield

View File

@@ -8,9 +8,17 @@ 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).
def test_tracker_mode_defaults_to_bump(monkeypatch):
# ORCH-067 (TC-01 / AC-1): the default flipped edit -> bump. With no env var
# the card now re-creates at the bottom of the chat out of the box; edit
# stays available via ORCH_TRACKER_MODE=edit (see test below).
monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False)
assert Settings().tracker_mode == "bump"
def test_tracker_mode_reads_env_edit(monkeypatch):
# ORCH-067 (AC-4): edit mode is still available through the env var.
monkeypatch.setenv("ORCH_TRACKER_MODE", "edit")
assert Settings().tracker_mode == "edit"
@@ -235,6 +243,7 @@ def test_tc19_qg_checks_registry_unchanged():
"check_staging_status",
"check_branch_mergeable",
"check_staging_image_fresh",
"check_security_gate",
}

View File

@@ -38,12 +38,28 @@ CONFIRM = "44444444-4444-4444-4444-444444444444"
# ORCH project: Confirm Deploy resolved. enduro-like project: NO confirm_deploy key.
_STATES_SELF = {
"in_progress": IN_PROGRESS,
"to_analyse": IN_PROGRESS, # ORCH-066 integ: always present (alias)
# ORCH-066 integ: full status-model keys alias to base UUIDs,
# mirroring get_project_states() _STATE_ALIAS_FALLBACK in production.
"analysis": IN_PROGRESS,
"code_review": APPROVED,
"awaiting_deploy": IN_PROGRESS,
"deploying": IN_PROGRESS,
"monitoring": APPROVED,
"approved": APPROVED,
"rejected": REJECTED,
"confirm_deploy": CONFIRM,
}
_STATES_NONSELF = {
"in_progress": IN_PROGRESS,
"to_analyse": IN_PROGRESS, # ORCH-066 integ: always present (alias)
# ORCH-066 integ: full status-model keys alias to base UUIDs,
# mirroring get_project_states() _STATE_ALIAS_FALLBACK in production.
"analysis": IN_PROGRESS,
"code_review": APPROVED,
"awaiting_deploy": IN_PROGRESS,
"deploying": IN_PROGRESS,
"monitoring": APPROVED,
"approved": APPROVED,
"rejected": REJECTED,
}

View File

@@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch):
"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",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -101,6 +104,7 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_security_gate": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
@@ -127,6 +131,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
assert _jobs() == []
# The restart-safe approve-requested marker was written.
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
# ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`.
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036")
stage_engine.set_issue_in_review.assert_not_called()
# ---------------------------------------------------------------------------
@@ -153,6 +160,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
# 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)
# ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate.
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036")
# 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again.
res2 = advance_stage(

View File

@@ -0,0 +1,188 @@
"""ORCH-071 — Phase C finalizer x merge-verify under-gate (integration).
Covers TC-05 (FR-3/G2/AC-1: deploy SUCCESS but PR open -> NOT done + alert),
TC-06 (AC-4: deploy SUCCESS + merge confirmed -> done) and TC-14 (AC-11: Phase B
runs only on confirm_deploy; merge/verify never introduce an auto-deploy).
Mirrors tests/test_deploy_terminal_sync.py: the finalizer drives advance_stage,
the deploy gate is forced green, and the merge-actor/verifier are mocked so the
test stays deterministic (no real Gitea/git).
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_verify.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))
# The under-gate is disabled by conftest default; these tests target it.
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_enabled", True)
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "")
# The merged_to_main stamp is an observability side effect (no log file here).
monkeypatch.setattr(
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
)
# ORCH-021 post-deploy monitor is orthogonal; keep it off for these tests.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
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", "set_issue_analysis",
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
def _pass(*a, **k):
return (True, "ok")
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-071-x", wi="ORCH-071"):
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 _force_deploy_gate_green(monkeypatch):
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
# ---------------------------------------------------------------------------
# TC-05 (AC-1): deploy_status=SUCCESS but PR open -> task is HELD (not done) + alert.
# ---------------------------------------------------------------------------
def test_tc05_success_but_not_merged_holds_and_alerts(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0")
_force_deploy_gate_green(monkeypatch)
# The merge-actor finds no merge and the verifier confirms NOT merged.
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", MagicMock(return_value=(False, "no open PR")))
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", MagicMock(return_value=False))
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
# AC-1 PASS: the task did NOT reach done and was Blocked for manual handling.
assert _stage(task_id) == "deploy"
assert stage_engine.set_issue_blocked.called
assert not stage_engine.set_issue_done.called
assert not stage_engine.set_issue_monitoring.called
# An alert was sent ("deploy succeeded but not merged").
assert stage_engine.send_telegram.called
# ---------------------------------------------------------------------------
# TC-06 (AC-4): deploy_status=SUCCESS + merge confirmed -> done (happy-path).
# ---------------------------------------------------------------------------
def test_tc06_success_and_merged_reaches_done(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0")
_force_deploy_gate_green(monkeypatch)
merge_pr = MagicMock(return_value=(True, "merged PR #1"))
verify = MagicMock(return_value=True)
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify)
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"
# The deterministic merge-actor + verifier both ran on the deploy->done edge.
assert merge_pr.called
assert verify.called
# Self-hosting: terminal status -> Monitoring (post_deploy off here -> Done set).
assert not stage_engine.set_issue_blocked.called
# ---------------------------------------------------------------------------
# TC-14 (AC-11): a plain Approved on `deploy` (confirm_deploy=False) is a no-op —
# Phase B (prod deploy) requires "Confirm Deploy", and merge/verify do NOT run
# (the under-gate never introduces an auto-deploy).
# ---------------------------------------------------------------------------
def test_tc14_plain_approved_on_deploy_is_noop_no_merge(monkeypatch):
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True)
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "")
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
merge_pr = MagicMock()
verify = MagicMock()
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify)
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy")
# finished_agent=None + confirm_deploy=False == a plain Approved on `deploy`.
result = stage_engine.advance_stage(
task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x",
finished_agent=None, confirm_deploy=False,
)
assert result.note == "approved-on-deploy-noop"
assert _stage(task_id) == "deploy"
# No prod deploy initiated and the merge-verify under-gate never fired.
assert not initiate.called
assert not merge_pr.called
assert not verify.called
def test_tc14_confirm_deploy_initiates_phase_b(monkeypatch):
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True)
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "")
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy")
stage_engine.advance_stage(
task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x",
finished_agent=None, confirm_deploy=True,
)
# Only the dedicated "Confirm Deploy" signal initiates the prod deploy.
assert initiate.called

View File

@@ -0,0 +1,116 @@
"""ORCH-071 TC-10 (AC-3/G3) — merge survives a restart during Phase B (smoke).
Scenario: the prod container "dies" during Phase B BEFORE the feature PR is merged
(the holder of the merge step is gone). Because the merge runs in the
restart-surviving Phase C finalizer (deploy->done under-gate), a re-drive of the
finalizer in the NEW container catches the merge up: it merges the PR, the verifier
turns green and the task finally reaches ``done`` — never stuck without an alert and
never ``done`` without a confirmed merge.
The first finalizer pass models "died before merge": the merge-actor cannot complete
and the verifier is red -> HOLD + alert (task stays on ``deploy``). The second pass
models the re-drive after the restart: the merge lands, verify is green -> ``done``.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_recovery.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.settings, "merge_verify_enabled", True)
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "")
monkeypatch.setattr(
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
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", "set_issue_analysis",
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
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 test_tc10_merge_recovers_after_restart(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")},
)
# Stateful merge: the FIRST attempt (pre-restart) cannot complete; the SECOND
# (the re-driven finalizer after the restart) merges and the verifier goes green.
state = {"attempts": 0, "merged": False}
def fake_merge_pr(repo, branch):
state["attempts"] += 1
if state["attempts"] == 1:
return (False, "interrupted by restart")
state["merged"] = True
return (True, "merged PR #1")
def fake_verify(repo, branch, sha):
return state["merged"]
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", fake_merge_pr)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", fake_verify)
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)",
("plane-ORCH-071", "ORCH-071", "orchestrator", "feature/ORCH-071-x", "deploy"),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
job = {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
# Pass 1 (process died before merge): HOLD — not done, alerted, Blocked.
stage_engine.run_deploy_finalizer(job)
assert _stage(task_id) == "deploy"
assert stage_engine.set_issue_blocked.called
assert not stage_engine.set_issue_done.called
# Pass 2 (finalizer re-driven after restart): merge lands, verify green -> done.
stage_engine.run_deploy_finalizer(job)
assert _stage(task_id) == "done"
assert state["merged"] is True

View File

@@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch):
"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",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -106,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
# No agent is launched leaving deploy (terminal).
assert _jobs() == []
# ---------------------------------------------------------------------------
# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy,
# NOT terminal Done. The post-deploy monitor finalises.
# ---------------------------------------------------------------------------
def test_tc08_self_deploy_done_sets_monitoring_not_done(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},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
# post_deploy applies for the self-hosting repo with the monitor enabled.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
# arm_monitor is orthogonal; stub it so this test stays on the status contract.
monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True))
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"
# Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet.
stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036")
stage_engine.set_issue_done.assert_not_called()
# ---------------------------------------------------------------------------
# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress).
# ---------------------------------------------------------------------------
def test_tc09_non_self_deploy_done_sets_done(monkeypatch):
self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
# Monitor enabled, but the empty CSV means it applies ONLY to the self repo;
# a non-self repo therefore takes the unchanged terminal-Done path.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
stage_engine.set_issue_done.assert_called_once_with("ET-042")
stage_engine.set_issue_monitoring.assert_not_called()

View File

@@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
_PROJECT_STATES = {
ENDURO_PLANE_ID: {
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
# ORCH-066: To Analyse is the start trigger; with the status absent it
# aliases to in_progress (the real get_project_states fallback).
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
},
ORCH_PLANE_ID: {
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
},

135
tests/test_merge_actor.py Normal file
View File

@@ -0,0 +1,135 @@
"""ORCH-071 — deterministic merge-actor (merge_gate.merge_pr).
Covers TC-07 (FR-1: merge via Gitea PR-merge API, no push/force-push), TC-08
(AC-9: idempotency — already-merged -> no-op), TC-09 (AC-7: never-raise) and TC-13
(AC-8/INV-2: self-hosting safety — no prod restart, no direct/force push to main).
Gitea HTTP is mocked; the actor must NEVER shell out to git/docker/ssh.
"""
import httpx
import pytest
from src import merge_gate
class _Resp:
def __init__(self, status_code, payload=None, text=""):
self.status_code = status_code
self._payload = payload if payload is not None else []
self.text = text
def json(self):
return self._payload
@pytest.fixture(autouse=True)
def _settings(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
# ---------------------------------------------------------------------------
# TC-07: an OPEN PR -> the actor calls Gitea POST /pulls/{index}/merge (Do: merge).
# ---------------------------------------------------------------------------
def test_tc07_merge_actor_calls_gitea_merge(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
branch = "feature/ORCH-071-x"
get_calls, post_calls = [], []
def fake_get(url, params=None, headers=None, timeout=None):
get_calls.append((url, params))
return _Resp(200, [{"head": {"ref": branch}, "number": 7}])
def fake_post(url, json=None, headers=None, timeout=None):
post_calls.append((url, json))
return _Resp(200)
monkeypatch.setattr(httpx, "get", fake_get)
monkeypatch.setattr(httpx, "post", fake_post)
ok, msg = merge_gate.merge_pr("orchestrator", branch)
assert ok is True
assert "PR #7" in msg
# POST hit the PR-merge API endpoint with Do=merge.
assert len(post_calls) == 1
url, body = post_calls[0]
assert url.endswith("/repos/admin/orchestrator/pulls/7/merge")
assert body == {"Do": "merge"}
# ---------------------------------------------------------------------------
# TC-08 (AC-9): already-merged PR -> no-op (no second merge, no Gitea error).
# ---------------------------------------------------------------------------
def test_tc08_idempotent_already_merged(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
def must_not_call(*a, **k):
raise AssertionError("no Gitea call must be made when already merged")
monkeypatch.setattr(httpx, "get", must_not_call)
monkeypatch.setattr(httpx, "post", must_not_call)
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
assert ok is True
assert msg == "already-merged"
def test_tc08_no_open_pr_is_not_an_error(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, []))
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
assert ok is False
assert msg == "no open PR"
# ---------------------------------------------------------------------------
# TC-09 (AC-7): a Gitea HTTP error -> (False, reason), exception not propagated.
# ---------------------------------------------------------------------------
def test_tc09_never_raise_on_http_error(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
def boom(*a, **k):
raise httpx.ConnectError("gitea unreachable")
monkeypatch.setattr(httpx, "get", boom)
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
assert ok is False
assert "merge error" in msg
def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}])
)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict"))
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
assert ok is False
assert "HTTP 409" in msg
# ---------------------------------------------------------------------------
# TC-13 (AC-8/INV-2): the merge-actor NEVER shells out (no git push/force-push,
# no docker/ssh prod restart) — the only side effect is the Gitea PR-merge API.
# ---------------------------------------------------------------------------
def test_tc13_no_shell_out_no_force_push(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}])
)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200))
subprocess_calls = []
monkeypatch.setattr(
merge_gate.subprocess, "run",
lambda cmd, *a, **k: subprocess_calls.append(cmd),
)
ok, _ = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
assert ok is True
# No subprocess (git/docker/ssh) was invoked by the merge-actor at all.
assert subprocess_calls == []

126
tests/test_merge_verify.py Normal file
View File

@@ -0,0 +1,126 @@
"""ORCH-071 — post-deploy merge verification + rollout conditionality.
Covers TC-01..04 (FR-2/G1/AC-1/AC-7: verify_merged_to_main), TC-11 (AC-4b: non-self
repo no-op) and TC-12 (AC-10: kill-switch). All deterministic: git/HTTP are mocked,
the verifier honours the never-raise contract.
"""
import pytest
from src import merge_gate
class _R:
"""Minimal stand-in for a completed subprocess result (returncode only)."""
def __init__(self, rc):
self.returncode = rc
self.stdout = ""
self.stderr = ""
@pytest.fixture(autouse=True)
def _enable(monkeypatch):
# The conftest disables the under-gate by default; these tests target it, so
# re-enable the feature and pin the scope to self-hosting only (empty CSV).
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "")
monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5)
# ---------------------------------------------------------------------------
# TC-01: validated SHA is an ancestor of origin/main (merge-base rc=0) -> True.
# ---------------------------------------------------------------------------
def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
calls = []
def fake_run(cmd, *a, **k):
calls.append(cmd)
# fetch -> rc 0; merge-base --is-ancestor -> rc 0 (is ancestor).
return _R(0)
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is True
# The verifier consulted git merge-base --is-ancestor on origin/main.
assert any("merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls)
# ---------------------------------------------------------------------------
# TC-02: PR.merged==true short-circuits to True even if git is unavailable.
# ---------------------------------------------------------------------------
def test_tc02_verify_true_when_pr_merged_even_without_git(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
def boom(*a, **k):
raise RuntimeError("git must NOT be consulted when PR is already merged")
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is True
# ---------------------------------------------------------------------------
# TC-03: not an ancestor (rc=1) AND PR not merged -> False (phantom merge).
# ---------------------------------------------------------------------------
def test_tc03_verify_false_when_phantom(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
def fake_run(cmd, *a, **k):
if "merge-base" in cmd:
return _R(1) # NOT an ancestor.
return _R(0) # fetch ok.
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
# ---------------------------------------------------------------------------
# TC-04 (AC-7): never-raise — a git/OS error -> False, exception not propagated.
# ---------------------------------------------------------------------------
def test_tc04_verify_never_raises_on_git_error(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
def boom(*a, **k):
raise OSError("git exploded")
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
# No exception escapes; the conservative verdict is "not confirmed".
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
def test_tc04_verify_never_raises_on_http_error(monkeypatch):
def boom(r, b):
raise RuntimeError("gitea down")
monkeypatch.setattr(merge_gate, "pr_already_merged", boom)
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
# ---------------------------------------------------------------------------
# TC-11 (AC-4b): non-self repo -> under-gate is a no-op (merge stays with deployer).
# ---------------------------------------------------------------------------
def test_tc11_non_self_repo_does_not_apply(monkeypatch):
# Empty CSV -> only the self-hosting repo is in scope.
assert merge_gate.merge_verify_applies("orchestrator") is True
assert merge_gate.merge_verify_applies("enduro-trails") is False
def test_tc11_csv_scopes_to_listed_repos(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "enduro-trails")
assert merge_gate.merge_verify_applies("enduro-trails") is True
# When the CSV is set, the self repo is NOT auto-included.
assert merge_gate.merge_verify_applies("orchestrator") is False
# ---------------------------------------------------------------------------
# TC-12 (AC-10): kill-switch off -> applies False for everyone (1:1 prior behaviour).
# ---------------------------------------------------------------------------
def test_tc12_kill_switch_disables_under_gate(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
assert merge_gate.merge_verify_applies("orchestrator") is False
assert merge_gate.merge_verify_applies("enduro-trails") is False

View File

@@ -0,0 +1,206 @@
"""ORCH-067 — Group D: clickable issue number in ALL alerts (AC-13, AC-12).
Every orchestrator alert that mentions a work_item_id now renders it as a Plane
hyperlink via the shared ``link_for`` / ``plane_issue_link`` helpers, and degrades
fail-safe to the raw (escaped) number when data is missing. This covers the
dedicated notify_* helpers (notify_approve_requested, notify_error) and asserts
the engine/launcher/security_gate/reconciler alert sites are wired to ``link_for``
— the single DB-resolving helper those sites call. Network is isolated:
send_telegram is replaced with a recorder; the DB is a temp SQLite.
Test ids TC-13, TC-14, TC-15 from 04-test-plan.yaml.
"""
import os
import tempfile
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_notify_links.db")
os.environ["ORCH_DB_PATH"] = _test_db
from types import SimpleNamespace # noqa: E402
import pytest # noqa: E402
import src.db as db_module # noqa: E402
import src.projects as projects_mod # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import notifications as N # noqa: E402
_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
@pytest.fixture(autouse=True)
def setup_db(monkeypatch):
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# Pin repo->project resolution so cross-file registry reloads can't strip
# 'orchestrator' and break the expected issue URL.
monkeypatch.setattr(
projects_mod, "get_project_by_repo",
lambda repo: (SimpleNamespace(plane_project_id=_ORCH_PROJECT_ID)
if repo == "orchestrator" else None),
)
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
def _set(monkeypatch, **kw):
s = N._get_settings()
for k, v in kw.items():
monkeypatch.setattr(s, k, v, raising=False)
def _mk_task(wid="ORCH-067", repo="orchestrator", title="notify links",
plane_issue_id="iss-1", stage="development"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, "
"plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
("p1", wid, repo, "feature/ORCH-067-x", stage, title, plane_issue_id),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _record_send(monkeypatch):
calls = []
def _fake(text, disable_notification=False):
calls.append({"text": text, "silent": disable_notification})
return 1
monkeypatch.setattr(N, "send_telegram", _fake)
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None)
return calls
# --------------------------------------------------------------------------- #
# TC-13 / AC-13 — notify_approve_requested: number clickable, CTA + single ping
# --------------------------------------------------------------------------- #
def test_tc13_approve_requested_number_clickable(monkeypatch):
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme", gitea_public_url="https://git.example.org",
gitea_owner="orchteam")
tid = _mk_task(plane_issue_id="iss-1")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
assert len(calls) == 1 # exactly one notifying ping
assert calls[0]["silent"] is not True
text = calls[0]["text"]
expected = (
f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}"
f"/issues/iss-1/"
)
assert f'<a href="{expected}">ORCH-067</a>' in text # clickable number
assert "Approved" in text # call-to-action preserved
# --------------------------------------------------------------------------- #
# TC-14 / AC-13, AC-12 — notify_error: clickable when data present, else raw
# --------------------------------------------------------------------------- #
def test_tc14_notify_error_clickable(monkeypatch):
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
tid = _mk_task(plane_issue_id="iss-1")
calls = _record_send(monkeypatch)
N.notify_error(tid, "boom happened")
assert len(calls) == 1
text = calls[0]["text"]
assert ">ORCH-067</a>" in text # number is a link
assert "ERROR" in text and "boom happened" in text
def test_tc14_notify_error_degrades_raw_number(monkeypatch):
# No usable Plane base -> raw (unlinked) number, alert still sent, no crash.
_set(monkeypatch, plane_web_url="", plane_api_url="")
tid = _mk_task(plane_issue_id="iss-1")
calls = _record_send(monkeypatch)
N.notify_error(tid, "boom")
text = calls[0]["text"]
assert "ORCH-067" in text
assert "<a href=" not in text
def test_tc14_notify_error_escapes_error_text(monkeypatch):
# The error string is html-escaped so it can't break the <a>/HTML markup.
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
tid = _mk_task(plane_issue_id="iss-1")
calls = _record_send(monkeypatch)
N.notify_error(tid, "<script> & </script>")
text = calls[0]["text"]
assert "<script>" not in text
assert "&lt;script&gt;" in text and "&amp;" in text
# The clickable number's anchor is still well-formed.
assert text.count("<a href=") == text.count("</a>")
# --------------------------------------------------------------------------- #
# TC-15 / AC-13 — link_for is the DB-resolving helper the alert sites call
# --------------------------------------------------------------------------- #
def test_tc15_link_for_by_work_item_id(monkeypatch):
# Sites holding only a work_item_id (launcher deploy-fail, security_gate,
# reconciler, engine QG-fail) call link_for(wid) -> resolves repo + issue id
# from the DB and returns a clickable number.
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
_mk_task(wid="ORCH-067", plane_issue_id="iss-1")
out = N.link_for("ORCH-067")
expected = (
f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}"
f"/issues/iss-1/"
)
assert out == f'<a href="{expected}">ORCH-067</a>'
def test_tc15_link_for_by_task_id(monkeypatch):
# Sites holding a task_id (launcher agent-fail, engine) call link_for(wid, tid).
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
tid = _mk_task(wid="ORCH-067", plane_issue_id="iss-7")
out = N.link_for("ORCH-067", tid)
assert ">ORCH-067</a>" in out and "/issues/iss-7/" in out
def test_tc15_link_for_unknown_task_degrades(monkeypatch):
# No matching DB row -> raw number, never raises.
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
out = N.link_for("ORCH-999")
assert out == "ORCH-999"
assert "<a href=" not in out
@pytest.mark.parametrize("module_name", [
"src.stage_engine",
"src.agents.launcher",
"src.security_gate",
"src.reconciler",
])
def test_tc15_alert_modules_wire_link_for(module_name):
"""The representative alert modules call the shared link_for helper, so their
work_item_id alerts render a clickable number (not a bare string). Checked at
source level since some sites import link_for function-locally."""
import importlib
import inspect
mod = importlib.import_module(module_name)
src = inspect.getsource(mod)
assert "link_for(" in src, f"{module_name} must use link_for in its alerts"

View File

@@ -460,3 +460,59 @@ def test_default_states_et_values():
assert ps._DEFAULT_STATES[key] == expected, (
f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}"
)
# ---------------------------------------------------------------------------
# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the
# new statuses, get_project_states must use its OWN UUID, not the default alias.
# ---------------------------------------------------------------------------
def test_orch066_tc19_name_resolution_beats_alias():
"""A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' /
'Deploying' / 'Monitoring after Deploy' resolves each to its own project
UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID."""
import src.plane_sync as ps
new_uuids = {
"Analysis": "11111111-0000-0000-0000-000000000001",
"Code-Review": "11111111-0000-0000-0000-000000000002",
"Awaiting Deploy": "11111111-0000-0000-0000-000000000003",
"Deploying": "11111111-0000-0000-0000-000000000004",
"Monitoring after Deploy": "11111111-0000-0000-0000-000000000005",
"To Analyse": "11111111-0000-0000-0000-000000000006",
}
# Start from the full ORCH base set, then add the dedicated new statuses.
results = _make_states_response(ORCH_STATES)["results"]
results += [{"id": uid, "name": name} for name, uid in new_uuids.items()]
with patch("src.plane_sync.httpx.get") as mock_get:
mock_get.return_value = _fake_response({"results": results})
states = ps.get_project_states(ORCH_PROJECT_ID)
# Each new key resolved to the project's OWN UUID, not the base-key alias.
assert states["analysis"] == new_uuids["Analysis"]
assert states["code_review"] == new_uuids["Code-Review"]
assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"]
assert states["deploying"] == new_uuids["Deploying"]
assert states["monitoring"] == new_uuids["Monitoring after Deploy"]
assert states["to_analyse"] == new_uuids["To Analyse"]
# Sanity: they are NOT the aliased base UUIDs.
assert states["analysis"] != states["in_progress"]
assert states["code_review"] != states["review"]
assert states["awaiting_deploy"] != states["in_review"]
def test_orch066_tc19_missing_new_status_aliases_to_project_base():
"""BR-12: a project WITHOUT the new statuses degrades each new key to its OWN
base UUID (not a foreign enduro UUID) — keeping the PATCH state valid."""
import src.plane_sync as ps
with patch("src.plane_sync.httpx.get") as mock_get:
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
states = ps.get_project_states(ORCH_PROJECT_ID)
# No dedicated new statuses -> alias to THIS project's base UUIDs.
assert states["analysis"] == ORCH_STATES["in_progress"]
assert states["to_analyse"] == ORCH_STATES["in_progress"]
assert states["code_review"] == ORCH_STATES["review"]
assert states["awaiting_deploy"] == ORCH_STATES["in_review"]
assert states["deploying"] == ORCH_STATES["in_progress"]
assert states["monitoring"] == ORCH_STATES["done"]

View File

@@ -38,6 +38,16 @@ CONFIRM = "44444444-4444-4444-4444-444444444444"
_STATES = {
"in_progress": IN_PROGRESS,
# ORCH-066 integ: real get_project_states always exposes to_analyse
# (aliased to in_progress on projects without the dedicated status).
"to_analyse": IN_PROGRESS,
# ORCH-066 integ: full status-model keys alias to base UUIDs,
# mirroring get_project_states() _STATE_ALIAS_FALLBACK in production.
"analysis": IN_PROGRESS,
"code_review": APPROVED,
"awaiting_deploy": IN_PROGRESS,
"deploying": IN_PROGRESS,
"monitoring": APPROVED,
"approved": APPROVED,
"rejected": REJECTED,
"confirm_deploy": CONFIRM,

View File

@@ -0,0 +1,101 @@
"""ORCH-067 — Group D: the shared plane_issue_link helper (AC-12).
``plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None)``
is the single source of the clickable issue number for cards AND alerts. It
returns ``<a href=...>ORCH-NNN</a>`` when a usable Plane browser URL can be built,
and ``html.escape(work_item_id)`` otherwise. It must NEVER raise — including on
None arguments and a loopback base. No DB and no network are touched by this unit
(project_id is passed explicitly here), so these are pure settings-driven cases.
Test id TC-12 from 04-test-plan.yaml.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
import pytest # noqa: E402
from src import notifications as N # noqa: E402
def _set(monkeypatch, **kw):
s = N._get_settings()
for k, v in kw.items():
monkeypatch.setattr(s, k, v, raising=False)
# --------------------------------------------------------------------------- #
# TC-12 / AC-12 — full data -> HTML link wrapping the number
# --------------------------------------------------------------------------- #
def test_tc12_full_data_returns_anchor(monkeypatch):
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
out = N.plane_issue_link("ORCH-067", plane_issue_id="iss-1",
project_id="proj-9")
expected = "https://plane.example.org/acme/projects/proj-9/issues/iss-1/"
assert out == f'<a href="{expected}">ORCH-067</a>'
def test_tc12_web_url_fallbacks_to_api_url(monkeypatch):
# plane_web_url empty -> non-loopback plane_api_url is used as the base.
_set(monkeypatch, plane_web_url="",
plane_api_url="https://plane-fallback.example.org",
plane_workspace_slug="acme")
out = N.plane_issue_link("ORCH-067", plane_issue_id="iss-1",
project_id="proj-9")
assert 'href="https://plane-fallback.example.org/acme/' in out
assert ">ORCH-067</a>" in out
# --------------------------------------------------------------------------- #
# TC-12 / AC-12 — insufficient data -> escaped number, NEVER an anchor
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("settings_kw,call_kw,reason", [
({"plane_web_url": "", "plane_api_url": ""},
{"plane_issue_id": "iss-1", "project_id": "proj-9"}, "no web base"),
({"plane_web_url": "http://localhost:8091", "plane_api_url": ""},
{"plane_issue_id": "iss-1", "project_id": "proj-9"}, "loopback base"),
({"plane_web_url": "https://plane.example.org", "plane_workspace_slug": ""},
{"plane_issue_id": "iss-1", "project_id": "proj-9"}, "no workspace"),
({"plane_web_url": "https://plane.example.org", "plane_workspace_slug": "acme"},
{"plane_issue_id": None, "project_id": "proj-9"}, "no issue id"),
({"plane_web_url": "https://plane.example.org", "plane_workspace_slug": "acme"},
{"plane_issue_id": "iss-1", "project_id": ""}, "no project id"),
])
def test_tc12_insufficient_data_returns_plain_number(monkeypatch, settings_kw,
call_kw, reason):
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_api_url="http://localhost:8091", plane_workspace_slug="acme")
_set(monkeypatch, **settings_kw)
out = N.plane_issue_link("ORCH-067", repo=None, **call_kw)
assert out == "ORCH-067", reason
assert "<a href=" not in out
# --------------------------------------------------------------------------- #
# TC-12 / AC-12 — html-escaping + never raises on hostile / None input
# --------------------------------------------------------------------------- #
def test_tc12_escapes_work_item_id_in_link(monkeypatch):
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
out = N.plane_issue_link("ORCH&<67>", plane_issue_id="iss-1",
project_id="proj-9")
assert ">ORCH&amp;&lt;67&gt;</a>" in out # label escaped inside the anchor
assert "<a href=" in out
def test_tc12_escapes_work_item_id_unlinked(monkeypatch):
_set(monkeypatch, plane_web_url="", plane_api_url="")
out = N.plane_issue_link("ORCH&<67>", plane_issue_id="iss-1",
project_id="proj-9")
assert out == "ORCH&amp;&lt;67&gt;" # escaped, no anchor
def test_tc12_none_args_never_raise(monkeypatch):
# All-None must not raise and must yield a (possibly empty) string.
out = N.plane_issue_link(None)
assert isinstance(out, str)
# None work_item_id -> empty label, no anchor.
assert "<a href=" not in out

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